diff --git a/.github/update.log b/.github/update.log index 0a6336e814..82b563bd32 100644 --- a/.github/update.log +++ b/.github/update.log @@ -1285,3 +1285,4 @@ Update On Tue Feb 24 20:15:23 CET 2026 Update On Wed Feb 25 20:17:35 CET 2026 Update On Thu Feb 26 20:04:04 CET 2026 Update On Fri Feb 27 19:57:47 CET 2026 +Update On Sat Feb 28 19:45:29 CET 2026 diff --git a/clash-nyanpasu/frontend/interface/package.json b/clash-nyanpasu/frontend/interface/package.json index 072d9ecf59..67e94c6456 100644 --- a/clash-nyanpasu/frontend/interface/package.json +++ b/clash-nyanpasu/frontend/interface/package.json @@ -18,7 +18,7 @@ "lodash-es": "4.17.23", "ofetch": "1.5.1", "react": "19.2.4", - "swr": "2.4.0" + "swr": "2.4.1" }, "devDependencies": { "@types/lodash-es": "4.17.12", diff --git a/clash-nyanpasu/frontend/nyanpasu/package.json b/clash-nyanpasu/frontend/nyanpasu/package.json index 6646cbc1e3..970cecba1f 100644 --- a/clash-nyanpasu/frontend/nyanpasu/package.json +++ b/clash-nyanpasu/frontend/nyanpasu/package.json @@ -67,7 +67,7 @@ "react-split-grid": "1.0.4", "react-use": "17.6.0", "rxjs": "7.8.2", - "swr": "2.4.0", + "swr": "2.4.1", "virtua": "0.46.6", "vite-bundle-visualizer": "1.2.1" }, @@ -75,7 +75,7 @@ "@csstools/normalize.css": "12.1.1", "@emotion/babel-plugin": "11.13.5", "@emotion/react": "11.14.0", - "@iconify/json": "2.2.443", + "@iconify/json": "2.2.444", "@monaco-editor/react": "4.7.0", "@tanstack/react-query": "5.90.21", "@tanstack/react-router": "1.161.4", diff --git a/clash-nyanpasu/manifest/version.json b/clash-nyanpasu/manifest/version.json index b5d388a508..eaf904fa47 100644 --- a/clash-nyanpasu/manifest/version.json +++ b/clash-nyanpasu/manifest/version.json @@ -2,7 +2,7 @@ "manifest_version": 1, "latest": { "mihomo": "v1.19.20", - "mihomo_alpha": "alpha-f6722ab", + "mihomo_alpha": "alpha-3035ae8", "clash_rs": "v0.9.4", "clash_premium": "2023-09-05-gdcc8d87", "clash_rs_alpha": "0.9.4-alpha+sha.ce8c715" @@ -69,5 +69,5 @@ "linux-armv7hf": "clash-armv7-unknown-linux-gnueabihf" } }, - "updated_at": "2026-02-25T22:29:27.456Z" + "updated_at": "2026-02-27T22:21:57.836Z" } diff --git a/clash-nyanpasu/package.json b/clash-nyanpasu/package.json index 742d392738..62a8935e91 100644 --- a/clash-nyanpasu/package.json +++ b/clash-nyanpasu/package.json @@ -62,14 +62,14 @@ "@tauri-apps/cli": "2.10.0", "@types/fs-extra": "11.0.4", "@types/lodash-es": "4.17.12", - "@types/node": "24.10.15", + "@types/node": "24.11.0", "autoprefixer": "10.4.27", "conventional-changelog-conventionalcommits": "9.1.0", "cross-env": "10.1.0", "dedent": "1.7.1", "globals": "17.3.0", "knip": "5.85.0", - "lint-staged": "16.2.7", + "lint-staged": "16.3.0", "npm-run-all2": "8.0.4", "oxlint": "1.50.0", "postcss": "8.5.6", diff --git a/clash-nyanpasu/pnpm-lock.yaml b/clash-nyanpasu/pnpm-lock.yaml index e80fcf1376..ce508d91a0 100644 --- a/clash-nyanpasu/pnpm-lock.yaml +++ b/clash-nyanpasu/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: devDependencies: '@commitlint/cli': specifier: 20.4.2 - version: 20.4.2(@types/node@24.10.15)(typescript@5.9.3) + version: 20.4.2(@types/node@24.11.0)(typescript@5.9.3) '@commitlint/config-conventional': specifier: 20.4.2 version: 20.4.2 @@ -41,8 +41,8 @@ importers: specifier: 4.17.12 version: 4.17.12 '@types/node': - specifier: 24.10.15 - version: 24.10.15 + specifier: 24.11.0 + version: 24.11.0 autoprefixer: specifier: 10.4.27 version: 10.4.27(postcss@8.5.6) @@ -60,10 +60,10 @@ importers: version: 17.3.0 knip: specifier: 5.85.0 - version: 5.85.0(@types/node@24.10.15)(typescript@5.9.3) + version: 5.85.0(@types/node@24.11.0)(typescript@5.9.3) lint-staged: - specifier: 16.2.7 - version: 16.2.7 + specifier: 16.3.0 + version: 16.3.0 npm-run-all2: specifier: 8.0.4 version: 8.0.4 @@ -149,8 +149,8 @@ importers: specifier: 19.2.4 version: 19.2.4 swr: - specifier: 2.4.0 - version: 2.4.0(react@19.2.4) + specifier: 2.4.1 + version: 2.4.1(react@19.2.4) devDependencies: '@types/lodash-es': specifier: 4.17.12 @@ -333,8 +333,8 @@ importers: specifier: 7.8.2 version: 7.8.2 swr: - specifier: 2.4.0 - version: 2.4.0(react@19.2.4) + specifier: 2.4.1 + version: 2.4.1(react@19.2.4) virtua: specifier: 0.46.6 version: 0.46.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.5) @@ -352,8 +352,8 @@ importers: specifier: 11.14.0 version: 11.14.0(@types/react@19.2.14)(react@19.2.4) '@iconify/json': - specifier: 2.2.443 - version: 2.2.443 + specifier: 2.2.444 + version: 2.2.444 '@monaco-editor/react': specifier: 4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -368,7 +368,7 @@ importers: version: 1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.161.4)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': specifier: 1.161.4 - version: 1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) '@tauri-apps/plugin-clipboard-manager': specifier: 2.3.2 version: 2.3.2 @@ -404,13 +404,13 @@ importers: version: 13.15.10 '@vitejs/plugin-legacy': specifier: 7.2.1 - version: 7.2.1(terser@5.36.0)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 7.2.1(terser@5.36.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitejs/plugin-react': specifier: 5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitejs/plugin-react-swc': specifier: 4.2.3 - version: 4.2.3(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.2.3(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) change-case: specifier: 5.4.4 version: 5.4.4 @@ -449,19 +449,19 @@ importers: version: 13.15.26 vite: specifier: 7.3.1 - version: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) vite-plugin-html: specifier: 3.2.2 - version: 3.2.2(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 3.2.2(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) vite-plugin-sass-dts: specifier: 1.3.35 - version: 1.3.35(postcss@8.5.6)(prettier@3.8.1)(sass-embedded@1.97.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 1.3.35(postcss@8.5.6)(prettier@3.8.1)(sass-embedded@1.97.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) vite-plugin-svgr: specifier: 4.5.0 - version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) vite-tsconfig-paths: specifier: 6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) zod: specifier: 4.3.6 version: 4.3.6 @@ -497,7 +497,7 @@ importers: version: 19.2.14 '@vitejs/plugin-react': specifier: 5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) ahooks: specifier: 3.9.6 version: 3.9.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -527,10 +527,10 @@ importers: version: 4.2.1 vite: specifier: 7.3.1 - version: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) vite-tsconfig-paths: specifier: 6.1.1 - version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) devDependencies: '@emotion/react': specifier: 11.14.0 @@ -555,7 +555,7 @@ importers: version: 5.2.0(typescript@5.9.3) vite-plugin-dts: specifier: 4.5.4 - version: 4.5.4(@types/node@24.10.15)(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)) + version: 4.5.4(@types/node@24.11.0)(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)) scripts: dependencies: @@ -1686,8 +1686,8 @@ packages: prettier-plugin-ember-template-tag: optional: true - '@iconify/json@2.2.443': - resolution: {integrity: sha512-xyDqw1FeuNWPhYj+Sp2I1kyD6J5U5s8GxEW+dgIY1/Vl0b65t+PlRVCxEBtx73CanfDUPrSEHbxKQJwzXrcV/w==} + '@iconify/json@2.2.444': + resolution: {integrity: sha512-z0UwFaVtaN/h/iWZ1kzEjqFU3sp0rRy93tzOtpepZU89DY39WsNeYZv2mxtft/2La6Bz2b4z1C/HkU5Cqv3gbw==} '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} @@ -3894,8 +3894,8 @@ packages: '@types/ms@0.7.34': resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} - '@types/node@24.10.15': - resolution: {integrity: sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==} + '@types/node@24.11.0': + resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -4490,6 +4490,10 @@ packages: resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} engines: {node: '>=20'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -5682,8 +5686,8 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lint-staged@16.2.7: - resolution: {integrity: sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow==} + lint-staged@16.3.0: + resolution: {integrity: sha512-YVHHy/p6U4/No9Af+35JLh3umJ9dPQnGTvNCbfO/T5fC60us0jFnc+vw33cqveI+kqxIFJQakcMVTO2KM+653A==} engines: {node: '>=20.17'} hasBin: true @@ -7079,8 +7083,8 @@ packages: svg-tags@1.0.0: resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} - swr@2.4.0: - resolution: {integrity: sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==} + swr@2.4.1: + resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -7134,6 +7138,10 @@ packages: tinyexec@1.0.1: resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -7591,6 +7599,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -8485,11 +8498,11 @@ snapshots: hashery: 1.3.0 keyv: 5.6.0 - '@commitlint/cli@20.4.2(@types/node@24.10.15)(typescript@5.9.3)': + '@commitlint/cli@20.4.2(@types/node@24.11.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.4.0 '@commitlint/lint': 20.4.2 - '@commitlint/load': 20.4.0(@types/node@24.10.15)(typescript@5.9.3) + '@commitlint/load': 20.4.0(@types/node@24.11.0)(typescript@5.9.3) '@commitlint/read': 20.4.0 '@commitlint/types': 20.4.0 tinyexec: 1.0.1 @@ -8536,14 +8549,14 @@ snapshots: '@commitlint/rules': 20.4.2 '@commitlint/types': 20.4.0 - '@commitlint/load@20.4.0(@types/node@24.10.15)(typescript@5.9.3)': + '@commitlint/load@20.4.0(@types/node@24.11.0)(typescript@5.9.3)': dependencies: '@commitlint/config-validator': 20.4.0 '@commitlint/execute-rule': 20.0.0 '@commitlint/resolve-extends': 20.4.0 '@commitlint/types': 20.4.0 cosmiconfig: 9.0.0(typescript@5.9.3) - cosmiconfig-typescript-loader: 6.1.0(@types/node@24.10.15)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) + cosmiconfig-typescript-loader: 6.1.0(@types/node@24.11.0)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3) is-plain-obj: 4.1.0 lodash.mergewith: 4.6.2 picocolors: 1.1.1 @@ -8894,7 +8907,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@iconify/json@2.2.443': + '@iconify/json@2.2.444': dependencies: '@iconify/types': 2.0.0 pathe: 2.0.3 @@ -8989,23 +9002,23 @@ snapshots: '@material/material-color-utilities@0.4.0': {} - '@microsoft/api-extractor-model@7.30.3(@types/node@24.10.15)': + '@microsoft/api-extractor-model@7.30.3(@types/node@24.11.0)': dependencies: '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.11.0(@types/node@24.10.15) + '@rushstack/node-core-library': 5.11.0(@types/node@24.11.0) transitivePeerDependencies: - '@types/node' - '@microsoft/api-extractor@7.51.0(@types/node@24.10.15)': + '@microsoft/api-extractor@7.51.0(@types/node@24.11.0)': dependencies: - '@microsoft/api-extractor-model': 7.30.3(@types/node@24.10.15) + '@microsoft/api-extractor-model': 7.30.3(@types/node@24.11.0) '@microsoft/tsdoc': 0.15.1 '@microsoft/tsdoc-config': 0.17.1 - '@rushstack/node-core-library': 5.11.0(@types/node@24.10.15) + '@rushstack/node-core-library': 5.11.0(@types/node@24.11.0) '@rushstack/rig-package': 0.5.3 - '@rushstack/terminal': 0.15.0(@types/node@24.10.15) - '@rushstack/ts-command-line': 4.23.5(@types/node@24.10.15) + '@rushstack/terminal': 0.15.0(@types/node@24.11.0) + '@rushstack/ts-command-line': 4.23.5(@types/node@24.11.0) lodash: 4.17.21 minimatch: 3.0.8 resolve: 1.22.8 @@ -10249,7 +10262,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.2': optional: true - '@rushstack/node-core-library@5.11.0(@types/node@24.10.15)': + '@rushstack/node-core-library@5.11.0(@types/node@24.11.0)': dependencies: ajv: 8.13.0 ajv-draft-04: 1.0.0(ajv@8.13.0) @@ -10260,23 +10273,23 @@ snapshots: resolve: 1.22.8 semver: 7.5.4 optionalDependencies: - '@types/node': 24.10.15 + '@types/node': 24.11.0 '@rushstack/rig-package@0.5.3': dependencies: resolve: 1.22.8 strip-json-comments: 3.1.1 - '@rushstack/terminal@0.15.0(@types/node@24.10.15)': + '@rushstack/terminal@0.15.0(@types/node@24.11.0)': dependencies: - '@rushstack/node-core-library': 5.11.0(@types/node@24.10.15) + '@rushstack/node-core-library': 5.11.0(@types/node@24.11.0) supports-color: 8.1.1 optionalDependencies: - '@types/node': 24.10.15 + '@types/node': 24.11.0 - '@rushstack/ts-command-line@4.23.5(@types/node@24.10.15)': + '@rushstack/ts-command-line@4.23.5(@types/node@24.11.0)': dependencies: - '@rushstack/terminal': 0.15.0(@types/node@24.10.15) + '@rushstack/terminal': 0.15.0(@types/node@24.11.0) '@types/argparse': 1.0.38 argparse: 1.0.10 string-argv: 0.3.2 @@ -10609,7 +10622,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': + '@tanstack/router-plugin@1.161.4(@tanstack/react-router@1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.0) @@ -10626,7 +10639,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.161.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -10766,7 +10779,7 @@ snapshots: '@types/adm-zip@0.5.7': dependencies: - '@types/node': 24.10.15 + '@types/node': 24.11.0 '@types/argparse@1.0.38': {} @@ -10927,7 +10940,7 @@ snapshots: '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 - '@types/node': 24.10.15 + '@types/node': 24.11.0 '@types/geojson@7946.0.14': {} @@ -10943,7 +10956,7 @@ snapshots: '@types/jsonfile@6.1.4': dependencies: - '@types/node': 24.10.15 + '@types/node': 24.11.0 '@types/lodash-es@4.17.12': dependencies: @@ -10957,7 +10970,7 @@ snapshots: '@types/ms@0.7.34': {} - '@types/node@24.10.15': + '@types/node@24.11.0': dependencies: undici-types: 7.16.0 @@ -11209,7 +11222,7 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vitejs/plugin-legacy@7.2.1(terser@5.36.0)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': + '@vitejs/plugin-legacy@7.2.1(terser@5.36.0)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.0 '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.28.0) @@ -11224,19 +11237,19 @@ snapshots: regenerator-runtime: 0.14.1 systemjs: 6.15.1 terser: 5.36.0 - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': + '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 '@swc/core': 1.15.11 - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@swc/helpers' - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -11244,7 +11257,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -11632,6 +11645,8 @@ snapshots: commander@14.0.2: {} + commander@14.0.3: {} + commander@2.20.3: {} commander@7.2.0: {} @@ -11703,9 +11718,9 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig-typescript-loader@6.1.0(@types/node@24.10.15)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): + cosmiconfig-typescript-loader@6.1.0(@types/node@24.11.0)(cosmiconfig@9.0.0(typescript@5.9.3))(typescript@5.9.3): dependencies: - '@types/node': 24.10.15 + '@types/node': 24.11.0 cosmiconfig: 9.0.0(typescript@5.9.3) jiti: 2.6.1 typescript: 5.9.3 @@ -12658,10 +12673,10 @@ snapshots: kind-of@6.0.3: {} - knip@5.85.0(@types/node@24.10.15)(typescript@5.9.3): + knip@5.85.0(@types/node@24.11.0)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 24.10.15 + '@types/node': 24.11.0 fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 @@ -12748,15 +12763,15 @@ snapshots: lines-and-columns@1.2.4: {} - lint-staged@16.2.7: + lint-staged@16.3.0: dependencies: - commander: 14.0.2 + commander: 14.0.3 listr2: 9.0.5 micromatch: 4.0.8 nano-spawn: 2.0.0 - pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.8.1 + tinyexec: 1.0.2 + yaml: 2.8.2 listr2@9.0.5: dependencies: @@ -14299,7 +14314,7 @@ snapshots: svg-tags@1.0.0: {} - swr@2.4.0(react@19.2.4): + swr@2.4.1(react@19.2.4): dependencies: dequal: 2.0.3 react: 19.2.4 @@ -14372,6 +14387,8 @@ snapshots: tinyexec@1.0.1: {} + tinyexec@1.0.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -14653,9 +14670,9 @@ snapshots: - rollup - supports-color - vite-plugin-dts@4.5.4(@types/node@24.10.15)(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-plugin-dts@4.5.4(@types/node@24.11.0)(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: - '@microsoft/api-extractor': 7.51.0(@types/node@24.10.15) + '@microsoft/api-extractor': 7.51.0(@types/node@24.11.0) '@rollup/pluginutils': 5.1.4(rollup@4.46.2) '@volar/typescript': 2.4.11 '@vue/language-core': 2.2.0(typescript@5.9.3) @@ -14666,13 +14683,13 @@ snapshots: magic-string: 0.30.17 typescript: 5.9.3 optionalDependencies: - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - rollup - supports-color - vite-plugin-html@3.2.2(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-plugin-html@3.2.2(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@rollup/pluginutils': 4.2.1 colorette: 2.0.20 @@ -14686,38 +14703,38 @@ snapshots: html-minifier-terser: 6.1.0 node-html-parser: 5.4.2 pathe: 0.2.0 - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-sass-dts@1.3.35(postcss@8.5.6)(prettier@3.8.1)(sass-embedded@1.97.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-plugin-sass-dts@1.3.35(postcss@8.5.6)(prettier@3.8.1)(sass-embedded@1.97.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: postcss: 8.5.6 postcss-js: 4.0.1(postcss@8.5.6) prettier: 3.8.1 sass-embedded: 1.97.3 - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) - vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-plugin-svgr@4.5.0(rollup@4.46.2)(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@rollup/pluginutils': 5.2.0(rollup@4.46.2) '@svgr/core': 8.1.0(typescript@5.9.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0(typescript@5.9.3)) - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - rollup - supports-color - typescript - vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1)): + vite-tsconfig-paths@6.1.1(typescript@5.9.3)(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.0.3(typescript@5.9.3) - vite: 7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.3.1(@types/node@24.10.15)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.1): + vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(less@4.2.0)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(stylus@0.62.0)(terser@5.36.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.2 fdir: 6.5.0(picomatch@4.0.3) @@ -14726,7 +14743,7 @@ snapshots: rollup: 4.46.2 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.15 + '@types/node': 24.11.0 fsevents: 2.3.3 jiti: 2.6.1 less: 4.2.0 @@ -14736,7 +14753,7 @@ snapshots: stylus: 0.62.0 terser: 5.36.0 tsx: 4.21.0 - yaml: 2.8.1 + yaml: 2.8.2 void-elements@3.1.0: {} @@ -14819,6 +14836,8 @@ snapshots: yaml@2.8.1: {} + yaml@2.8.2: {} + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} diff --git a/filebrowser/CHANGELOG.md b/filebrowser/CHANGELOG.md index 6a55edba6c..27de0fe03e 100644 --- a/filebrowser/CHANGELOG.md +++ b/filebrowser/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [2.61.0](https://github.com/filebrowser/filebrowser/compare/v2.60.0...v2.61.0) (2026-02-28) + + +### Features + +* improved conflict resolution when uploading/copying/moving files ([#5765](https://github.com/filebrowser/filebrowser/issues/5765)) ([aa80909](https://github.com/filebrowser/filebrowser/commit/aa809096eb35fdfbdeb6784b1ebfe2ca1e42f52b)) + + +### Bug Fixes + +* correctly clean path ([31194fb](https://github.com/filebrowser/filebrowser/commit/31194fb57a5b92e7155219d7ec7273028fcb2e83)) + ## [2.60.0](https://github.com/filebrowser/filebrowser/compare/v2.59.0...v2.60.0) (2026-02-21) diff --git a/filebrowser/frontend/package.json b/filebrowser/frontend/package.json index e98566b976..6f12769a7d 100644 --- a/filebrowser/frontend/package.json +++ b/filebrowser/frontend/package.json @@ -72,5 +72,5 @@ "vite-plugin-compression2": "^2.3.1", "vue-tsc": "^3.1.3" }, - "packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a" + "packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017" } diff --git a/filebrowser/frontend/pnpm-lock.yaml b/filebrowser/frontend/pnpm-lock.yaml index 7ec45c7af3..8107bb98a8 100644 --- a/filebrowser/frontend/pnpm-lock.yaml +++ b/filebrowser/frontend/pnpm-lock.yaml @@ -10,13 +10,13 @@ importers: dependencies: '@chenfengyuan/vue-number-input': specifier: ^2.0.1 - version: 2.0.1(vue@3.5.28(typescript@5.9.3)) + version: 2.0.1(vue@3.5.29(typescript@5.9.3)) '@vueuse/core': specifier: ^14.0.0 - version: 14.2.1(vue@3.5.28(typescript@5.9.3)) + version: 14.2.1(vue@3.5.29(typescript@5.9.3)) '@vueuse/integrations': specifier: ^14.0.0 - version: 14.2.1(focus-trap@8.0.0)(jwt-decode@4.0.0)(vue@3.5.28(typescript@5.9.3)) + version: 14.2.1(focus-trap@8.0.0)(jwt-decode@4.0.0)(vue@3.5.29(typescript@5.9.3)) ace-builds: specifier: ^1.43.2 version: 1.43.6 @@ -58,13 +58,13 @@ importers: version: 8.0.1 pinia: specifier: ^3.0.4 - version: 3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)) + version: 3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)) pretty-bytes: specifier: ^7.1.0 version: 7.1.0 qrcode.vue: specifier: ^3.6.0 - version: 3.8.0(vue@3.5.28(typescript@5.9.3)) + version: 3.8.0(vue@3.5.29(typescript@5.9.3)) tus-js-client: specifier: ^4.3.1 version: 4.3.1 @@ -79,13 +79,13 @@ importers: version: 0.2.30 videojs-mobile-ui: specifier: ^1.1.1 - version: 1.2.1(video.js@8.23.7) + version: 1.2.2(video.js@8.23.7) vue: specifier: ^3.5.17 - version: 3.5.28(typescript@5.9.3) + version: 3.5.29(typescript@5.9.3) vue-i18n: specifier: ^11.1.10 - version: 11.2.8(vue@3.5.28(typescript@5.9.3)) + version: 11.2.8(vue@3.5.29(typescript@5.9.3)) vue-lazyload: specifier: ^3.0.0 version: 3.0.0 @@ -94,14 +94,14 @@ importers: version: 1.3.4 vue-router: specifier: ^5.0.0 - version: 5.0.3(@vue/compiler-sfc@3.5.28)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(vue@3.5.28(typescript@5.9.3)) + version: 5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) vue-toastification: specifier: ^2.0.0-rc.5 - version: 2.0.0-rc.5(vue@3.5.28(typescript@5.9.3)) + version: 2.0.0-rc.5(vue@3.5.29(typescript@5.9.3)) devDependencies: '@intlify/unplugin-vue-i18n': specifier: ^11.0.1 - version: 11.0.7(@vue/compiler-dom@3.5.28)(eslint@10.0.1)(rollup@4.57.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.28(typescript@5.9.3)))(vue@3.5.28(typescript@5.9.3)) + version: 11.0.7(@vue/compiler-dom@3.5.29)(eslint@10.0.2)(rollup@4.57.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) '@tsconfig/node24': specifier: ^24.0.2 version: 24.0.4 @@ -110,40 +110,40 @@ importers: version: 4.17.12 '@types/node': specifier: ^24.10.1 - version: 24.10.13 + version: 24.11.0 '@typescript-eslint/eslint-plugin': specifier: ^8.37.0 - version: 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(typescript@5.9.3) + version: 8.56.1(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(typescript@5.9.3) '@vitejs/plugin-legacy': specifier: ^7.2.1 - version: 7.2.1(terser@5.46.0)(vite@7.3.1(@types/node@24.10.13)(terser@5.46.0)(yaml@2.8.2)) + version: 7.2.1(terser@5.46.0)(vite@7.3.1(@types/node@24.11.0)(terser@5.46.0)(yaml@2.8.2)) '@vitejs/plugin-vue': specifier: ^6.0.1 - version: 6.0.4(vite@7.3.1(@types/node@24.10.13)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.28(typescript@5.9.3)) + version: 6.0.4(vite@7.3.1(@types/node@24.11.0)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3)) '@vue/eslint-config-prettier': specifier: ^10.2.0 - version: 10.2.0(eslint@10.0.1)(prettier@3.8.1) + version: 10.2.0(eslint@10.0.2)(prettier@3.8.1) '@vue/eslint-config-typescript': specifier: ^14.6.0 - version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(vue-eslint-parser@10.4.0(eslint@10.0.1)))(eslint@10.0.1)(typescript@5.9.3) + version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(vue-eslint-parser@10.4.0(eslint@10.0.2)))(eslint@10.0.2)(typescript@5.9.3) '@vue/tsconfig': specifier: ^0.8.1 - version: 0.8.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)) + version: 0.8.1(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)) autoprefixer: specifier: ^10.4.21 - version: 10.4.24(postcss@8.5.6) + version: 10.4.27(postcss@8.5.6) eslint: specifier: ^10.0.0 - version: 10.0.1 + version: 10.0.2 eslint-config-prettier: specifier: ^10.1.5 - version: 10.1.8(eslint@10.0.1) + version: 10.1.8(eslint@10.0.2) eslint-plugin-prettier: specifier: ^5.5.1 - version: 5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.1))(eslint@10.0.1)(prettier@3.8.1) + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.2))(eslint@10.0.2)(prettier@3.8.1) eslint-plugin-vue: specifier: ^10.5.1 - version: 10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(vue-eslint-parser@10.4.0(eslint@10.0.1)) + version: 10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(vue-eslint-parser@10.4.0(eslint@10.0.2)) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -158,7 +158,7 @@ importers: version: 5.9.3 vite: specifier: ^7.2.2 - version: 7.3.1(@types/node@24.10.13)(terser@5.46.0)(yaml@2.8.2) + version: 7.3.1(@types/node@24.11.0)(terser@5.46.0)(yaml@2.8.2) vite-plugin-compression2: specifier: ^2.3.1 version: 2.4.0(rollup@4.57.1) @@ -1288,8 +1288,8 @@ packages: '@types/lodash@4.17.23': resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + '@types/node@24.11.0': + resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1305,6 +1305,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/parser@8.56.0': resolution: {integrity: sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1318,16 +1326,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/scope-manager@8.56.0': resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.0': resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.56.0': resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1335,16 +1359,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/types@8.56.0': resolution: {integrity: sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.0': resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.56.0': resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1352,10 +1393,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/visitor-keys@8.56.0': resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@videojs/http-streaming@3.17.4': resolution: {integrity: sha512-XAvdG2dolBuV2Fx8bu1kjmQ2D4TonGzZH68Pgv/O9xMSFWdZtITSMFismeQLEAtMmGwze8qNJp3RgV+jStrJqg==} engines: {node: '>=8', npm: '>=5'} @@ -1404,15 +1456,27 @@ packages: '@vue/compiler-core@3.5.28': resolution: {integrity: sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==} + '@vue/compiler-core@3.5.29': + resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==} + '@vue/compiler-dom@3.5.28': resolution: {integrity: sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==} + '@vue/compiler-dom@3.5.29': + resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==} + '@vue/compiler-sfc@3.5.28': resolution: {integrity: sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==} + '@vue/compiler-sfc@3.5.29': + resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==} + '@vue/compiler-ssr@3.5.28': resolution: {integrity: sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==} + '@vue/compiler-ssr@3.5.29': + resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==} + '@vue/devtools-api@6.6.4': resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} @@ -1454,23 +1518,26 @@ packages: '@vue/language-core@3.2.5': resolution: {integrity: sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==} - '@vue/reactivity@3.5.28': - resolution: {integrity: sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==} + '@vue/reactivity@3.5.29': + resolution: {integrity: sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==} - '@vue/runtime-core@3.5.28': - resolution: {integrity: sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==} + '@vue/runtime-core@3.5.29': + resolution: {integrity: sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==} - '@vue/runtime-dom@3.5.28': - resolution: {integrity: sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==} + '@vue/runtime-dom@3.5.29': + resolution: {integrity: sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==} - '@vue/server-renderer@3.5.28': - resolution: {integrity: sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==} + '@vue/server-renderer@3.5.29': + resolution: {integrity: sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==} peerDependencies: - vue: 3.5.28 + vue: 3.5.29 '@vue/shared@3.5.28': resolution: {integrity: sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==} + '@vue/shared@3.5.29': + resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} + '@vue/tsconfig@0.8.1': resolution: {integrity: sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==} peerDependencies: @@ -1581,8 +1648,8 @@ packages: resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} engines: {node: '>=20.19.0'} - autoprefixer@10.4.24: - resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -1611,9 +1678,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.3: - resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} - engines: {node: 20 || >=22} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} @@ -1628,9 +1695,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -1654,6 +1721,9 @@ packages: caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} + caniuse-lite@1.0.30001774: + resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -1823,8 +1893,8 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - eslint@10.0.1: - resolution: {integrity: sha512-20MV9SUdeN6Jd84xESsKhRly+/vxI+hwvpBMA93s+9dAcjdCuCojn4IqUGS3lvVaqjVYGYHSRMCpeFtF2rQYxQ==} + eslint@10.0.2: + resolution: {integrity: sha512-uYixubwmqJZH+KLVYIVKY1JQt7tysXhtj21WSvjcSmU5SVNzMus1bgLe+pAt816yQ8opKfheVVoPLqvVMGejYw==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} hasBin: true peerDependencies: @@ -2161,8 +2231,8 @@ packages: min-document@2.19.2: resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} minimatch@9.0.5: @@ -2569,8 +2639,8 @@ packages: videojs-hotkeys@0.2.30: resolution: {integrity: sha512-G8kEQZPapoWDoEajh2Nroy4bCN1qVEul5AuzZqBS7ZCG45K7hqTYKgf1+fmYvG8m8u84sZmVMUvSWZBjaFW66Q==} - videojs-mobile-ui@1.2.1: - resolution: {integrity: sha512-XlATK+ptFbynuZLFkTE1lJBXD21h4yFD4YFxrBweCnmbdja0+aHJduGZf0i3b6I/KquRX7S+gra2S5ZCdgE74A==} + videojs-mobile-ui@1.2.2: + resolution: {integrity: sha512-XPGgfQac4UhCRK4EJdJ6ODrQwj+ui0oGWzi+g5GFoIdzh4NcCk8PxwhvraSId6lSmMSOhazrrxY9Y/p30OKkjQ==} engines: {node: '>=14', npm: '>=6'} peerDependencies: video.js: ^8 @@ -2668,8 +2738,8 @@ packages: peerDependencies: typescript: '>=5.0.0' - vue@3.5.28: - resolution: {integrity: sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==} + vue@3.5.29: + resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -3364,9 +3434,9 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@chenfengyuan/vue-number-input@2.0.1(vue@3.5.28(typescript@5.9.3))': + '@chenfengyuan/vue-number-input@2.0.1(vue@3.5.29(typescript@5.9.3))': dependencies: - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) '@esbuild/aix-ppc64@0.25.12': optional: true @@ -3524,9 +3594,9 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.0.1)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.0.2)': dependencies: - eslint: 10.0.1 + eslint: 10.0.2 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} @@ -3535,7 +3605,7 @@ snapshots: dependencies: '@eslint/object-schema': 3.0.2 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color @@ -3565,7 +3635,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@intlify/bundle-utils@11.0.7(vue-i18n@11.2.8(vue@3.5.28(typescript@5.9.3)))': + '@intlify/bundle-utils@11.0.7(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))': dependencies: '@intlify/message-compiler': 11.2.8 '@intlify/shared': 11.2.8 @@ -3577,7 +3647,7 @@ snapshots: source-map-js: 1.2.1 yaml-eslint-parser: 1.3.2 optionalDependencies: - vue-i18n: 11.2.8(vue@3.5.28(typescript@5.9.3)) + vue-i18n: 11.2.8(vue@3.5.29(typescript@5.9.3)) '@intlify/core-base@11.2.8': dependencies: @@ -3591,12 +3661,12 @@ snapshots: '@intlify/shared@11.2.8': {} - '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.28)(eslint@10.0.1)(rollup@4.57.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.28(typescript@5.9.3)))(vue@3.5.28(typescript@5.9.3))': + '@intlify/unplugin-vue-i18n@11.0.7(@vue/compiler-dom@3.5.29)(eslint@10.0.2)(rollup@4.57.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1) - '@intlify/bundle-utils': 11.0.7(vue-i18n@11.2.8(vue@3.5.28(typescript@5.9.3))) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) + '@intlify/bundle-utils': 11.0.7(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3))) '@intlify/shared': 11.2.8 - '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.28)(vue-i18n@11.2.8(vue@3.5.28(typescript@5.9.3)))(vue@3.5.28(typescript@5.9.3)) + '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.29)(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)) '@rollup/pluginutils': 5.3.0(rollup@4.57.1) '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) @@ -3605,9 +3675,9 @@ snapshots: pathe: 2.0.3 picocolors: 1.1.1 unplugin: 2.3.11 - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) optionalDependencies: - vue-i18n: 11.2.8(vue@3.5.28(typescript@5.9.3)) + vue-i18n: 11.2.8(vue@3.5.29(typescript@5.9.3)) transitivePeerDependencies: - '@vue/compiler-dom' - eslint @@ -3615,14 +3685,14 @@ snapshots: - supports-color - typescript - '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.28)(vue-i18n@11.2.8(vue@3.5.28(typescript@5.9.3)))(vue@3.5.28(typescript@5.9.3))': + '@intlify/vue-i18n-extensions@8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.29)(vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3))': dependencies: '@babel/parser': 7.29.0 optionalDependencies: '@intlify/shared': 11.2.8 - '@vue/compiler-dom': 3.5.28 - vue: 3.5.28(typescript@5.9.3) - vue-i18n: 11.2.8(vue@3.5.28(typescript@5.9.3)) + '@vue/compiler-dom': 3.5.29 + vue: 3.5.29(typescript@5.9.3) + vue-i18n: 11.2.8(vue@3.5.29(typescript@5.9.3)) '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -3765,7 +3835,7 @@ snapshots: '@types/lodash@4.17.23': {} - '@types/node@24.10.13': + '@types/node@24.11.0': dependencies: undici-types: 7.16.0 @@ -3774,15 +3844,15 @@ snapshots: '@types/web-bluetooth@0.0.21': {} - '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.0(eslint@10.0.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@10.0.2)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.0 - '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.56.0(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 - eslint: 10.0.1 + eslint: 10.0.2 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -3790,14 +3860,30 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.0(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 10.0.2 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - eslint: 10.0.1 + eslint: 10.0.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3811,22 +3897,52 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.56.0(eslint@10.0.1)(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.0(eslint@10.0.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.2)(typescript@5.9.3) debug: 4.4.3 - eslint: 10.0.1 + eslint: 10.0.2 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/type-utils@8.56.1(eslint@10.0.2)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@10.0.2)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.0.2 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -3834,6 +3950,8 @@ snapshots: '@typescript-eslint/types@8.56.0': {} + '@typescript-eslint/types@8.56.1': {} + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) @@ -3849,13 +3967,39 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.56.0(eslint@10.0.1)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1) + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.0(eslint@10.0.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) '@typescript-eslint/scope-manager': 8.56.0 '@typescript-eslint/types': 8.56.0 '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - eslint: 10.0.1 + eslint: 10.0.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@10.0.2)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 10.0.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3865,6 +4009,11 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + '@videojs/http-streaming@3.17.4(video.js@8.23.7)': dependencies: '@babel/runtime': 7.28.6 @@ -3887,7 +4036,7 @@ snapshots: global: 4.4.0 is-function: 1.0.2 - '@vitejs/plugin-legacy@7.2.1(terser@5.46.0)(vite@7.3.1(@types/node@24.10.13)(terser@5.46.0)(yaml@2.8.2))': + '@vitejs/plugin-legacy@7.2.1(terser@5.46.0)(vite@7.3.1(@types/node@24.11.0)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-dynamic-import': 7.27.1(@babel/core@7.29.0) @@ -3902,15 +4051,15 @@ snapshots: regenerator-runtime: 0.14.1 systemjs: 6.15.1 terser: 5.46.0 - vite: 7.3.1(@types/node@24.10.13)(terser@5.46.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.11.0)(terser@5.46.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.10.13)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.28(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.11.0)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.29(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 7.3.1(@types/node@24.10.13)(terser@5.46.0)(yaml@2.8.2) - vue: 3.5.28(typescript@5.9.3) + vite: 7.3.1(@types/node@24.11.0)(terser@5.46.0)(yaml@2.8.2) + vue: 3.5.29(typescript@5.9.3) '@volar/language-core@2.4.28': dependencies: @@ -3924,7 +4073,7 @@ snapshots: path-browserify: 1.0.1 vscode-uri: 3.1.0 - '@vue-macros/common@3.1.2(vue@3.5.28(typescript@5.9.3))': + '@vue-macros/common@3.1.2(vue@3.5.29(typescript@5.9.3))': dependencies: '@vue/compiler-sfc': 3.5.28 ast-kit: 2.2.0 @@ -3932,7 +4081,7 @@ snapshots: magic-string-ast: 1.0.3 unplugin-utils: 0.3.1 optionalDependencies: - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) '@vue/compiler-core@3.5.28': dependencies: @@ -3942,11 +4091,24 @@ snapshots: estree-walker: 2.0.2 source-map-js: 1.2.1 + '@vue/compiler-core@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.29 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + '@vue/compiler-dom@3.5.28': dependencies: '@vue/compiler-core': 3.5.28 '@vue/shared': 3.5.28 + '@vue/compiler-dom@3.5.29': + dependencies: + '@vue/compiler-core': 3.5.29 + '@vue/shared': 3.5.29 + '@vue/compiler-sfc@3.5.28': dependencies: '@babel/parser': 7.29.0 @@ -3959,11 +4121,28 @@ snapshots: postcss: 8.5.6 source-map-js: 1.2.1 + '@vue/compiler-sfc@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.29 + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + '@vue/compiler-ssr@3.5.28': dependencies: '@vue/compiler-dom': 3.5.28 '@vue/shared': 3.5.28 + '@vue/compiler-ssr@3.5.29': + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/shared': 3.5.29 + '@vue/devtools-api@6.6.4': {} '@vue/devtools-api@7.7.9': @@ -4002,23 +4181,23 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/eslint-config-prettier@10.2.0(eslint@10.0.1)(prettier@3.8.1)': + '@vue/eslint-config-prettier@10.2.0(eslint@10.0.2)(prettier@3.8.1)': dependencies: - eslint: 10.0.1 - eslint-config-prettier: 10.1.8(eslint@10.0.1) - eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.1))(eslint@10.0.1)(prettier@3.8.1) + eslint: 10.0.2 + eslint-config-prettier: 10.1.8(eslint@10.0.2) + eslint-plugin-prettier: 5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.2))(eslint@10.0.2)(prettier@3.8.1) prettier: 3.8.1 transitivePeerDependencies: - '@types/eslint' - '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(vue-eslint-parser@10.4.0(eslint@10.0.1)))(eslint@10.0.1)(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(vue-eslint-parser@10.4.0(eslint@10.0.2)))(eslint@10.0.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1)(typescript@5.9.3) - eslint: 10.0.1 - eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(vue-eslint-parser@10.4.0(eslint@10.0.1)) + '@typescript-eslint/utils': 8.56.0(eslint@10.0.2)(typescript@5.9.3) + eslint: 10.0.2 + eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(vue-eslint-parser@10.4.0(eslint@10.0.2)) fast-glob: 3.3.3 - typescript-eslint: 8.56.0(eslint@10.0.1)(typescript@5.9.3) - vue-eslint-parser: 10.4.0(eslint@10.0.1) + typescript-eslint: 8.56.0(eslint@10.0.2)(typescript@5.9.3) + vue-eslint-parser: 10.4.0(eslint@10.0.2) optionalDependencies: typescript: 5.9.3 transitivePeerDependencies: @@ -4034,56 +4213,58 @@ snapshots: path-browserify: 1.0.1 picomatch: 4.0.3 - '@vue/reactivity@3.5.28': + '@vue/reactivity@3.5.29': dependencies: - '@vue/shared': 3.5.28 + '@vue/shared': 3.5.29 - '@vue/runtime-core@3.5.28': + '@vue/runtime-core@3.5.29': dependencies: - '@vue/reactivity': 3.5.28 - '@vue/shared': 3.5.28 + '@vue/reactivity': 3.5.29 + '@vue/shared': 3.5.29 - '@vue/runtime-dom@3.5.28': + '@vue/runtime-dom@3.5.29': dependencies: - '@vue/reactivity': 3.5.28 - '@vue/runtime-core': 3.5.28 - '@vue/shared': 3.5.28 + '@vue/reactivity': 3.5.29 + '@vue/runtime-core': 3.5.29 + '@vue/shared': 3.5.29 csstype: 3.2.3 - '@vue/server-renderer@3.5.28(vue@3.5.28(typescript@5.9.3))': + '@vue/server-renderer@3.5.29(vue@3.5.29(typescript@5.9.3))': dependencies: - '@vue/compiler-ssr': 3.5.28 - '@vue/shared': 3.5.28 - vue: 3.5.28(typescript@5.9.3) + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + vue: 3.5.29(typescript@5.9.3) '@vue/shared@3.5.28': {} - '@vue/tsconfig@0.8.1(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3))': + '@vue/shared@3.5.29': {} + + '@vue/tsconfig@0.8.1(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3))': optionalDependencies: typescript: 5.9.3 - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) - '@vueuse/core@14.2.1(vue@3.5.28(typescript@5.9.3))': + '@vueuse/core@14.2.1(vue@3.5.29(typescript@5.9.3))': dependencies: '@types/web-bluetooth': 0.0.21 '@vueuse/metadata': 14.2.1 - '@vueuse/shared': 14.2.1(vue@3.5.28(typescript@5.9.3)) - vue: 3.5.28(typescript@5.9.3) + '@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3)) + vue: 3.5.29(typescript@5.9.3) - '@vueuse/integrations@14.2.1(focus-trap@8.0.0)(jwt-decode@4.0.0)(vue@3.5.28(typescript@5.9.3))': + '@vueuse/integrations@14.2.1(focus-trap@8.0.0)(jwt-decode@4.0.0)(vue@3.5.29(typescript@5.9.3))': dependencies: - '@vueuse/core': 14.2.1(vue@3.5.28(typescript@5.9.3)) - '@vueuse/shared': 14.2.1(vue@3.5.28(typescript@5.9.3)) - vue: 3.5.28(typescript@5.9.3) + '@vueuse/core': 14.2.1(vue@3.5.29(typescript@5.9.3)) + '@vueuse/shared': 14.2.1(vue@3.5.29(typescript@5.9.3)) + vue: 3.5.29(typescript@5.9.3) optionalDependencies: focus-trap: 8.0.0 jwt-decode: 4.0.0 '@vueuse/metadata@14.2.1': {} - '@vueuse/shared@14.2.1(vue@3.5.28(typescript@5.9.3))': + '@vueuse/shared@14.2.1(vue@3.5.29(typescript@5.9.3))': dependencies: - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) '@xmldom/xmldom@0.7.13': {} @@ -4125,10 +4306,10 @@ snapshots: '@babel/parser': 7.29.0 ast-kit: 2.2.0 - autoprefixer@10.4.24(postcss@8.5.6): + autoprefixer@10.4.27(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 + caniuse-lite: 1.0.30001774 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -4168,7 +4349,7 @@ snapshots: balanced-match@1.0.2: {} - balanced-match@4.0.3: {} + balanced-match@4.0.4: {} baseline-browser-mapping@2.9.19: {} @@ -4180,9 +4361,9 @@ snapshots: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.2: + brace-expansion@5.0.4: dependencies: - balanced-match: 4.0.3 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -4205,6 +4386,8 @@ snapshots: caniuse-lite@1.0.30001769: {} + caniuse-lite@1.0.30001774: {} + chokidar@5.0.0: dependencies: readdirp: 5.0.0 @@ -4373,31 +4556,31 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@10.0.1): + eslint-config-prettier@10.1.8(eslint@10.0.2): dependencies: - eslint: 10.0.1 + eslint: 10.0.2 - eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.1))(eslint@10.0.1)(prettier@3.8.1): + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@10.0.2))(eslint@10.0.2)(prettier@3.8.1): dependencies: - eslint: 10.0.1 + eslint: 10.0.2 prettier: 3.8.1 prettier-linter-helpers: 1.0.1 synckit: 0.11.12 optionalDependencies: - eslint-config-prettier: 10.1.8(eslint@10.0.1) + eslint-config-prettier: 10.1.8(eslint@10.0.2) - eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(vue-eslint-parser@10.4.0(eslint@10.0.1)): + eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(vue-eslint-parser@10.4.0(eslint@10.0.2)): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1) - eslint: 10.0.1 + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) + eslint: 10.0.2 natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 7.1.1 semver: 7.7.4 - vue-eslint-parser: 10.4.0(eslint@10.0.1) + vue-eslint-parser: 10.4.0(eslint@10.0.2) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.56.0(eslint@10.0.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@10.0.2)(typescript@5.9.3) eslint-scope@9.1.1: dependencies: @@ -4410,9 +4593,9 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.0.1: + eslint@10.0.2: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.0.2) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.23.2 '@eslint/config-helpers': 0.5.2 @@ -4439,7 +4622,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.2 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -4743,9 +4926,9 @@ snapshots: dependencies: dom-walk: 0.1.2 - minimatch@10.2.2: + minimatch@10.2.4: dependencies: - brace-expansion: 5.0.2 + brace-expansion: 5.0.4 minimatch@9.0.5: dependencies: @@ -4831,10 +5014,10 @@ snapshots: picomatch@4.0.3: {} - pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)): + pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)): dependencies: '@vue/devtools-api': 7.7.9 - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) optionalDependencies: typescript: 5.9.3 @@ -4889,9 +5072,9 @@ snapshots: punycode@2.3.1: {} - qrcode.vue@3.8.0(vue@3.5.28(typescript@5.9.3)): + qrcode.vue@3.8.0(vue@3.5.29(typescript@5.9.3)): dependencies: - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) quansync@0.2.11: {} @@ -5069,13 +5252,13 @@ snapshots: type@2.7.3: {} - typescript-eslint@8.56.0(eslint@10.0.1)(typescript@5.9.3): + typescript-eslint@8.56.0(eslint@10.0.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(typescript@5.9.3) - '@typescript-eslint/parser': 8.56.0(eslint@10.0.1)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.2)(typescript@5.9.3))(eslint@10.0.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.0(eslint@10.0.2)(typescript@5.9.3) '@typescript-eslint/typescript-estree': 8.56.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.56.0(eslint@10.0.1)(typescript@5.9.3) - eslint: 10.0.1 + '@typescript-eslint/utils': 8.56.0(eslint@10.0.2)(typescript@5.9.3) + eslint: 10.0.2 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -5160,7 +5343,7 @@ snapshots: videojs-hotkeys@0.2.30: {} - videojs-mobile-ui@1.2.1(video.js@8.23.7): + videojs-mobile-ui@1.2.2(video.js@8.23.7): dependencies: global: 4.4.0 video.js: 8.23.7 @@ -5176,7 +5359,7 @@ snapshots: transitivePeerDependencies: - rollup - vite@7.3.1(@types/node@24.10.13)(terser@5.46.0)(yaml@2.8.2): + vite@7.3.1(@types/node@24.11.0)(terser@5.46.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -5185,17 +5368,17 @@ snapshots: rollup: 4.57.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 24.11.0 fsevents: 2.3.3 terser: 5.46.0 yaml: 2.8.2 vscode-uri@3.1.0: {} - vue-eslint-parser@10.4.0(eslint@10.0.1): + vue-eslint-parser@10.4.0(eslint@10.0.2): dependencies: debug: 4.4.3 - eslint: 10.0.1 + eslint: 10.0.2 eslint-scope: 9.1.1 eslint-visitor-keys: 5.0.1 espree: 11.1.1 @@ -5204,12 +5387,12 @@ snapshots: transitivePeerDependencies: - supports-color - vue-i18n@11.2.8(vue@3.5.28(typescript@5.9.3)): + vue-i18n@11.2.8(vue@3.5.29(typescript@5.9.3)): dependencies: '@intlify/core-base': 11.2.8 '@intlify/shared': 11.2.8 '@vue/devtools-api': 6.6.4 - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) vue-lazyload@3.0.0: {} @@ -5217,10 +5400,10 @@ snapshots: dependencies: epubjs: 0.3.93 - vue-router@5.0.3(@vue/compiler-sfc@3.5.28)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)))(vue@3.5.28(typescript@5.9.3)): + vue-router@5.0.3(@vue/compiler-sfc@3.5.29)(pinia@3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)))(vue@3.5.29(typescript@5.9.3)): dependencies: '@babel/generator': 7.29.1 - '@vue-macros/common': 3.1.2(vue@3.5.28(typescript@5.9.3)) + '@vue-macros/common': 3.1.2(vue@3.5.29(typescript@5.9.3)) '@vue/devtools-api': 8.0.6 ast-walker-scope: 0.8.3 chokidar: 5.0.0 @@ -5235,15 +5418,15 @@ snapshots: tinyglobby: 0.2.15 unplugin: 3.0.0 unplugin-utils: 0.3.1 - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) yaml: 2.8.2 optionalDependencies: - '@vue/compiler-sfc': 3.5.28 - pinia: 3.0.4(typescript@5.9.3)(vue@3.5.28(typescript@5.9.3)) + '@vue/compiler-sfc': 3.5.29 + pinia: 3.0.4(typescript@5.9.3)(vue@3.5.29(typescript@5.9.3)) - vue-toastification@2.0.0-rc.5(vue@3.5.28(typescript@5.9.3)): + vue-toastification@2.0.0-rc.5(vue@3.5.29(typescript@5.9.3)): dependencies: - vue: 3.5.28(typescript@5.9.3) + vue: 3.5.29(typescript@5.9.3) vue-tsc@3.2.5(typescript@5.9.3): dependencies: @@ -5251,13 +5434,13 @@ snapshots: '@vue/language-core': 3.2.5 typescript: 5.9.3 - vue@3.5.28(typescript@5.9.3): + vue@3.5.29(typescript@5.9.3): dependencies: - '@vue/compiler-dom': 3.5.28 - '@vue/compiler-sfc': 3.5.28 - '@vue/runtime-dom': 3.5.28 - '@vue/server-renderer': 3.5.28(vue@3.5.28(typescript@5.9.3)) - '@vue/shared': 3.5.28 + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-sfc': 3.5.29 + '@vue/runtime-dom': 3.5.29 + '@vue/server-renderer': 3.5.29(vue@3.5.29(typescript@5.9.3)) + '@vue/shared': 3.5.29 optionalDependencies: typescript: 5.9.3 diff --git a/filebrowser/frontend/src/i18n/ar.json b/filebrowser/frontend/src/i18n/ar.json index cf600a381b..5df31c3ff4 100644 --- a/filebrowser/frontend/src/i18n/ar.json +++ b/filebrowser/frontend/src/i18n/ar.json @@ -48,7 +48,11 @@ "saveChanges": "حفظ التغييرات", "editAsText": "تعديل على شكل نص", "increaseFontSize": "زيادة حجم الخط", - "decreaseFontSize": "تصغير حجم الخط" + "decreaseFontSize": "تصغير حجم الخط", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "تحميل الملف", @@ -161,7 +165,17 @@ "uploadMessage": "إختر الملفات التي تريد رفعها.", "optionalPassword": "كلمة مرور إختيارية", "resolution": "الدقة", - "discardEditorChanges": "هل تريد بالتأكيد إلغاء التغييرات؟" + "discardEditorChanges": "هل تريد بالتأكيد إلغاء التغييرات؟", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "الصور", diff --git a/filebrowser/frontend/src/i18n/bg.json b/filebrowser/frontend/src/i18n/bg.json index db4ece315a..582c1059e3 100644 --- a/filebrowser/frontend/src/i18n/bg.json +++ b/filebrowser/frontend/src/i18n/bg.json @@ -48,7 +48,11 @@ "saveChanges": "Запиши промените", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Свали файл", @@ -161,7 +165,17 @@ "uploadMessage": "Изберете опция за качване.", "optionalPassword": "Опционална парола", "resolution": "Резолюция", - "discardEditorChanges": "Сигурни ли сте, че искате да откажете направените промени?" + "discardEditorChanges": "Сигурни ли сте, че искате да откажете направените промени?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Изображения", diff --git a/filebrowser/frontend/src/i18n/ca.json b/filebrowser/frontend/src/i18n/ca.json index da6dfcc99a..c9e64b0f2d 100644 --- a/filebrowser/frontend/src/i18n/ca.json +++ b/filebrowser/frontend/src/i18n/ca.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Descarregar fitxer", @@ -161,7 +165,17 @@ "uploadMessage": "Seleccioneu una opció per pujar.", "optionalPassword": "Contrasenya opcional", "resolution": "Resolució", - "discardEditorChanges": "Esteu segur que voleu descartar els canvis que heu fet?" + "discardEditorChanges": "Esteu segur que voleu descartar els canvis que heu fet?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Imatges", diff --git a/filebrowser/frontend/src/i18n/cs.json b/filebrowser/frontend/src/i18n/cs.json index 560e015c91..5d08768ac6 100644 --- a/filebrowser/frontend/src/i18n/cs.json +++ b/filebrowser/frontend/src/i18n/cs.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Stáhnout soubor", @@ -161,7 +165,17 @@ "uploadMessage": "Vyberte možnost pro nahrání.", "optionalPassword": "Volitelné heslo", "resolution": "Rozlišení", - "discardEditorChanges": "Opravdu chcete zrušit provedené změny?" + "discardEditorChanges": "Opravdu chcete zrušit provedené změny?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Obrázky", diff --git a/filebrowser/frontend/src/i18n/de.json b/filebrowser/frontend/src/i18n/de.json index d7519c8bb3..bacaf0fa96 100644 --- a/filebrowser/frontend/src/i18n/de.json +++ b/filebrowser/frontend/src/i18n/de.json @@ -48,7 +48,11 @@ "saveChanges": "Änderungen speichern", "editAsText": "Als Text bearbeiten", "increaseFontSize": "Schrift vergrößern", - "decreaseFontSize": "Schrift verkleinern" + "decreaseFontSize": "Schrift verkleinern", + "overrideAll": "Alle Dateien im Zielordner ersetzen", + "skipAll": "Alle in Konflikt stehenden Dateien überspringen", + "renameAll": "Alle Dateien umbenennen (Kopie erstellen)", + "singleDecision": "Bei jeder Datei mit Konflikt entscheiden" }, "download": { "downloadFile": "Datei herunterladen", @@ -161,7 +165,17 @@ "uploadMessage": "Wähle eine Upload-Option.", "optionalPassword": "Optionales Passwort", "resolution": "Auflösung", - "discardEditorChanges": "Möchtest du deine Änderungen wirklich verwerfen?" + "discardEditorChanges": "Möchtest du deine Änderungen wirklich verwerfen?", + "replaceOrSkip": "Dateien ersetzen oder überspringen", + "resolveConflict": "Welche Dateien möchtest du behalten?", + "singleConflictResolve": "Wenn du beide Versionen auswählst, wird dem Namen der kopierten Datei eine Nummer hinzugefügt.", + "fastConflictResolve": "Der Zielordner enthält {count} Dateien mit demselben Namen.", + "uploadingFiles": "Dateien werden hochgeladen", + "filesInOrigin": "Dateien im Ursprungsordner", + "filesInDest": "Dateien im Zielordner", + "override": "Überschreiben", + "skip": "Überspringen", + "forbiddenError": "Zugriff verweigert" }, "search": { "images": "Bilder", diff --git a/filebrowser/frontend/src/i18n/el.json b/filebrowser/frontend/src/i18n/el.json index ab0e0914f5..0ab2b58971 100644 --- a/filebrowser/frontend/src/i18n/el.json +++ b/filebrowser/frontend/src/i18n/el.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Λήψη αρχείου", @@ -161,7 +165,17 @@ "uploadMessage": "Επιλέξτε μια επιλογή για τη μεταφόρτωση.", "optionalPassword": "Προαιρετικός κωδικός πρόσβασης", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Εικόνες", diff --git a/filebrowser/frontend/src/i18n/es.json b/filebrowser/frontend/src/i18n/es.json index 94b77c490c..9f06513755 100644 --- a/filebrowser/frontend/src/i18n/es.json +++ b/filebrowser/frontend/src/i18n/es.json @@ -48,7 +48,11 @@ "saveChanges": "Guardar cambios", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Descargar fichero", @@ -161,7 +165,17 @@ "uploadMessage": "Seleccione una opción para subir.", "optionalPassword": "Contraseña opcional", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Imágenes", diff --git a/filebrowser/frontend/src/i18n/fa.json b/filebrowser/frontend/src/i18n/fa.json index 53fef115dd..9f4504bd09 100644 --- a/filebrowser/frontend/src/i18n/fa.json +++ b/filebrowser/frontend/src/i18n/fa.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "دانلود فایل", @@ -161,7 +165,17 @@ "uploadMessage": "یک گزینه برای آپلود انتخاب کنید.", "optionalPassword": "رمز عبور اختیاری", "resolution": "وضوح تصویر", - "discardEditorChanges": "آیا مطمئن هستید که می‌خواهید تغییراتی را که ایجاد کرده‌اید، لغو کنید؟" + "discardEditorChanges": "آیا مطمئن هستید که می‌خواهید تغییراتی را که ایجاد کرده‌اید، لغو کنید؟", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "تصاویر", diff --git a/filebrowser/frontend/src/i18n/fr.json b/filebrowser/frontend/src/i18n/fr.json index 3f9a36e889..5d39372c1c 100644 --- a/filebrowser/frontend/src/i18n/fr.json +++ b/filebrowser/frontend/src/i18n/fr.json @@ -48,7 +48,11 @@ "saveChanges": "Enregistrer changements", "editAsText": "Editer comme Texte", "increaseFontSize": "Augmenter taille police", - "decreaseFontSize": "Réduire taille police" + "decreaseFontSize": "Réduire taille police", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Télécharger le fichier", @@ -161,7 +165,17 @@ "uploadMessage": "Sélectionnez une option d'import.", "optionalPassword": "Mot de passe optionnel", "resolution": "Résolution", - "discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?" + "discardEditorChanges": "Êtes-vous sûr de vouloir annuler les modifications apportées ?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Images", diff --git a/filebrowser/frontend/src/i18n/he.json b/filebrowser/frontend/src/i18n/he.json index a9aed87d45..dbb3263cc2 100644 --- a/filebrowser/frontend/src/i18n/he.json +++ b/filebrowser/frontend/src/i18n/he.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "הורד קובץ", @@ -161,7 +165,17 @@ "uploadMessage": "בחר אפשרות העלאה.", "optionalPassword": "סיסמא אופציונלית", "resolution": "Resolution", - "discardEditorChanges": "האם אתה בטוח שברצונך לבטל את השינויים שביצעת?" + "discardEditorChanges": "האם אתה בטוח שברצונך לבטל את השינויים שביצעת?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "תמונות", diff --git a/filebrowser/frontend/src/i18n/hr.json b/filebrowser/frontend/src/i18n/hr.json index df1fb6da7c..1cb4368f0d 100644 --- a/filebrowser/frontend/src/i18n/hr.json +++ b/filebrowser/frontend/src/i18n/hr.json @@ -48,7 +48,11 @@ "saveChanges": "Spremi promjene", "editAsText": "Uredi kao tekst", "increaseFontSize": "Povećaj veličinu fonta", - "decreaseFontSize": "Smanji veličinu fonta" + "decreaseFontSize": "Smanji veličinu fonta", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Preuzmi Datoteku", @@ -161,7 +165,17 @@ "uploadMessage": "Odaberite opciju za prijenos.", "optionalPassword": "Opcionalna lozinka", "resolution": "Rezolucija", - "discardEditorChanges": "Jeste li sigurni da želite odbaciti promjene koje ste napravili?" + "discardEditorChanges": "Jeste li sigurni da želite odbaciti promjene koje ste napravili?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Slike", diff --git a/filebrowser/frontend/src/i18n/hu.json b/filebrowser/frontend/src/i18n/hu.json index 38732646ae..bdcb33ba00 100644 --- a/filebrowser/frontend/src/i18n/hu.json +++ b/filebrowser/frontend/src/i18n/hu.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Fájl letöltése", @@ -161,7 +165,17 @@ "uploadMessage": "Válasszon egy feltöltési módot.", "optionalPassword": "Választható jelszó", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Képek", diff --git a/filebrowser/frontend/src/i18n/is.json b/filebrowser/frontend/src/i18n/is.json index 6b7d8abbd5..3825849218 100644 --- a/filebrowser/frontend/src/i18n/is.json +++ b/filebrowser/frontend/src/i18n/is.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Sækja skjal", @@ -161,7 +165,17 @@ "uploadMessage": "Select an option to upload.", "optionalPassword": "Optional password", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Myndir", diff --git a/filebrowser/frontend/src/i18n/it.json b/filebrowser/frontend/src/i18n/it.json index a17eb7461e..f3e7478abf 100644 --- a/filebrowser/frontend/src/i18n/it.json +++ b/filebrowser/frontend/src/i18n/it.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Scarica file", @@ -161,7 +165,17 @@ "uploadMessage": "Seleziona un'opzione per il caricamento.", "optionalPassword": "Password opzionale", "resolution": "Risoluzione", - "discardEditorChanges": "Sei sicuro di voler scartare le modifiche apportate?" + "discardEditorChanges": "Sei sicuro di voler scartare le modifiche apportate?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Immagini", diff --git a/filebrowser/frontend/src/i18n/ja.json b/filebrowser/frontend/src/i18n/ja.json index 853fe8d8ab..bf623ec224 100644 --- a/filebrowser/frontend/src/i18n/ja.json +++ b/filebrowser/frontend/src/i18n/ja.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "ファイルのダウンロード", @@ -161,7 +165,17 @@ "uploadMessage": "アップロードするオプションを選択してください。", "optionalPassword": "パスワード(オプション)", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "画像", diff --git a/filebrowser/frontend/src/i18n/ko.json b/filebrowser/frontend/src/i18n/ko.json index cd9252c2d6..7ff52dfc76 100644 --- a/filebrowser/frontend/src/i18n/ko.json +++ b/filebrowser/frontend/src/i18n/ko.json @@ -48,7 +48,11 @@ "saveChanges": "변경사항 저장", "editAsText": "텍스트로 편집", "increaseFontSize": "글꼴 크기 증가", - "decreaseFontSize": "글꼴 크기 감소" + "decreaseFontSize": "글꼴 크기 감소", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "파일 다운로드", @@ -161,7 +165,17 @@ "uploadMessage": "업로드 옵션을 선택하세요.", "optionalPassword": "비밀번호 (선택)", "resolution": "해상도", - "discardEditorChanges": "변경 사항을 취소하시겠습니까?" + "discardEditorChanges": "변경 사항을 취소하시겠습니까?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "이미지", diff --git a/filebrowser/frontend/src/i18n/lv.json b/filebrowser/frontend/src/i18n/lv.json index 2d55756691..e852b17026 100644 --- a/filebrowser/frontend/src/i18n/lv.json +++ b/filebrowser/frontend/src/i18n/lv.json @@ -48,7 +48,11 @@ "saveChanges": "Saglabāt izmaiņas", "editAsText": "Rediģēt kā tekstu", "increaseFontSize": "Palieliniet fonta lielumu", - "decreaseFontSize": "Samaziniet fonta lielumu" + "decreaseFontSize": "Samaziniet fonta lielumu", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Lejupielādēt failu", @@ -161,7 +165,17 @@ "uploadMessage": "Atlasiet augšupielādes opciju.", "optionalPassword": "Izvēles parole", "resolution": "Izšķirtspēja", - "discardEditorChanges": "Vai tiešām vēlaties atmest veiktās izmaiņas?" + "discardEditorChanges": "Vai tiešām vēlaties atmest veiktās izmaiņas?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Attēli", diff --git a/filebrowser/frontend/src/i18n/lv_LV.json b/filebrowser/frontend/src/i18n/lv_LV.json index 67fbdb8e0e..748614d600 100644 --- a/filebrowser/frontend/src/i18n/lv_LV.json +++ b/filebrowser/frontend/src/i18n/lv_LV.json @@ -48,7 +48,11 @@ "saveChanges": "Saglabāt izmaiņas", "editAsText": "Rediģēt kā tekstu", "increaseFontSize": "Palieliniet fonta lielumu", - "decreaseFontSize": "Samaziniet fonta lielumu" + "decreaseFontSize": "Samaziniet fonta lielumu", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Lejupielādēt failu", @@ -161,7 +165,17 @@ "uploadMessage": "Atlasiet augšupielādes opciju.", "optionalPassword": "Izvēles parole", "resolution": "Izšķirtspēja", - "discardEditorChanges": "Vai tiešām vēlaties atmest veiktās izmaiņas?" + "discardEditorChanges": "Vai tiešām vēlaties atmest veiktās izmaiņas?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Attēli", diff --git a/filebrowser/frontend/src/i18n/nl-be.json b/filebrowser/frontend/src/i18n/nl-be.json index d9f76406c7..ee27329e97 100644 --- a/filebrowser/frontend/src/i18n/nl-be.json +++ b/filebrowser/frontend/src/i18n/nl-be.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Bestand downloaden", @@ -161,7 +165,17 @@ "uploadMessage": "Select an option to upload.", "optionalPassword": "Optional password", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Afbeeldingen", diff --git a/filebrowser/frontend/src/i18n/nl.json b/filebrowser/frontend/src/i18n/nl.json index 4701af98c4..5d507a5535 100644 --- a/filebrowser/frontend/src/i18n/nl.json +++ b/filebrowser/frontend/src/i18n/nl.json @@ -48,7 +48,11 @@ "saveChanges": "Wijzigingen opslaan", "editAsText": "Als tekst bewerken", "increaseFontSize": "Lettertype vergroten", - "decreaseFontSize": "Lettertype verkleinen" + "decreaseFontSize": "Lettertype verkleinen", + "overrideAll": "Alle bestanden in doelmap vervangen", + "skipAll": "Alle conflicterende bestanden overslaan", + "renameAll": "Alle bestanden hernoemen (een kopie maken)", + "singleDecision": "Voor elk conflicterend bestand besluiten" }, "download": { "downloadFile": "Bestand downloaden", @@ -161,7 +165,17 @@ "uploadMessage": "Kies een optie bij de upload.", "optionalPassword": "Optioneel wachtwoord", "resolution": "Resolutie", - "discardEditorChanges": "Weet u zeker dat u de door u aangebrachte wijzigingen wilt weggooien?" + "discardEditorChanges": "Weet u zeker dat u de door u aangebrachte wijzigingen wilt weggooien?", + "replaceOrSkip": "Bestanden vervangen of overslaan", + "resolveConflict": "Welke bestanden wilt u behouden?", + "singleConflictResolve": "Als u beide versies selecteert, wordt er een nummer toegevoegd aan de naam van het gekopieerde bestand.", + "fastConflictResolve": "De doelmap daar bevat {count} bestanden met dezelfde naam.", + "uploadingFiles": "Bestanden uploaden", + "filesInOrigin": "Bestanden in origineel", + "filesInDest": "Bestanden in bestemming", + "override": "Overschrijven", + "skip": "Overslaan", + "forbiddenError": "Verboden Fout" }, "search": { "images": "Afbeeldingen", diff --git a/filebrowser/frontend/src/i18n/no.json b/filebrowser/frontend/src/i18n/no.json index 4c9fb225ee..dd82ee85f8 100644 --- a/filebrowser/frontend/src/i18n/no.json +++ b/filebrowser/frontend/src/i18n/no.json @@ -48,7 +48,11 @@ "saveChanges": "Lagre Endringane ", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Nedlast filen", @@ -161,7 +165,17 @@ "uploadMessage": "Velg et alternativ for opplasting.", "optionalPassword": "Valgfritt passord", "resolution": "Oppløysning", - "discardEditorChanges": "Er du sikker på at du vil forkaste endringene du har gjort?" + "discardEditorChanges": "Er du sikker på at du vil forkaste endringene du har gjort?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Bilde", diff --git a/filebrowser/frontend/src/i18n/pl.json b/filebrowser/frontend/src/i18n/pl.json index 7956e22bc0..2a46352892 100644 --- a/filebrowser/frontend/src/i18n/pl.json +++ b/filebrowser/frontend/src/i18n/pl.json @@ -48,7 +48,11 @@ "saveChanges": "Zapisz zmiany", "editAsText": "Edytuj jako tekst", "increaseFontSize": "Zwiększ rozmiar czcionki", - "decreaseFontSize": "Zmniejsz rozmiar czcionki" + "decreaseFontSize": "Zmniejsz rozmiar czcionki", + "overrideAll": "Zastąp wszystkie pliki w folderze docelowym", + "skipAll": "Pomiń wszystkie pliki powodujące konflikty", + "renameAll": "Zmień nazwy wszystkich plików (utwórz kopię)", + "singleDecision": "Podejmij decyzję dla każdego pliku powodującego konflikt" }, "download": { "downloadFile": "Pobierz plik", @@ -161,7 +165,17 @@ "uploadMessage": "Wybierz opcję przesyłania.", "optionalPassword": "Opcjonalne hasło", "resolution": "Rozdzielczość", - "discardEditorChanges": "Czy na pewno chcesz odrzucić wprowadzone zmiany?" + "discardEditorChanges": "Czy na pewno chcesz odrzucić wprowadzone zmiany?", + "replaceOrSkip": "Zastąp lub pomiń pliki", + "resolveConflict": "Które pliki chcesz zachować?", + "singleConflictResolve": "Jeśli wybierzesz obie wersje, do nazwy kopiowanego pliku zostanie dodany numer.", + "fastConflictResolve": "W folderze docelowym liczba plików o tej samej nazwie to {count}.", + "uploadingFiles": "Wysyłanie plików", + "filesInOrigin": "Pliki w miejscu pochodzenia", + "filesInDest": "Pliki w miejscu docelowym", + "override": "Zastąp", + "skip": "Pomiń", + "forbiddenError": "Błąd zabronionego dostępu" }, "search": { "images": "Obrazy", diff --git a/filebrowser/frontend/src/i18n/pt-br.json b/filebrowser/frontend/src/i18n/pt-br.json index 5f36c337ea..9b9122c65b 100644 --- a/filebrowser/frontend/src/i18n/pt-br.json +++ b/filebrowser/frontend/src/i18n/pt-br.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Baixar arquivo", @@ -161,7 +165,17 @@ "uploadMessage": "Selecione uma opção para enviar.", "optionalPassword": "Senha opcional", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Imagens", diff --git a/filebrowser/frontend/src/i18n/pt.json b/filebrowser/frontend/src/i18n/pt.json index 0e50052255..8f5e852455 100644 --- a/filebrowser/frontend/src/i18n/pt.json +++ b/filebrowser/frontend/src/i18n/pt.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Descarregar ficheiro", @@ -161,7 +165,17 @@ "uploadMessage": "Select an option to upload.", "optionalPassword": "Optional password", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Imagens", diff --git a/filebrowser/frontend/src/i18n/ro.json b/filebrowser/frontend/src/i18n/ro.json index 5495e884d8..b36436ea1b 100644 --- a/filebrowser/frontend/src/i18n/ro.json +++ b/filebrowser/frontend/src/i18n/ro.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Descarcă fișier", @@ -161,7 +165,17 @@ "uploadMessage": "Select an option to upload.", "optionalPassword": "Optional password", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Imagini", diff --git a/filebrowser/frontend/src/i18n/ru.json b/filebrowser/frontend/src/i18n/ru.json index 05514e4a64..6cd70e1317 100644 --- a/filebrowser/frontend/src/i18n/ru.json +++ b/filebrowser/frontend/src/i18n/ru.json @@ -48,7 +48,11 @@ "saveChanges": "Сохранить", "editAsText": "Редактировать как текст", "increaseFontSize": "Увеличить размер шрифта", - "decreaseFontSize": "Уменьшить размер шрифта" + "decreaseFontSize": "Уменьшить размер шрифта", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Скачать файл", @@ -161,7 +165,17 @@ "uploadMessage": "Выберите вариант для загрузки.", "optionalPassword": "Необязательный пароль", "resolution": "Разрешение", - "discardEditorChanges": "Вы действительно желаете отменить ваши правки?" + "discardEditorChanges": "Вы действительно желаете отменить ваши правки?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Изображения", diff --git a/filebrowser/frontend/src/i18n/sk.json b/filebrowser/frontend/src/i18n/sk.json index 225a4f748e..82089d7ba2 100644 --- a/filebrowser/frontend/src/i18n/sk.json +++ b/filebrowser/frontend/src/i18n/sk.json @@ -48,7 +48,11 @@ "saveChanges": "Uložiť zmeny", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Stiahnuť súbor", @@ -161,7 +165,17 @@ "uploadMessage": "Zvoľte možnosť nahrávania.", "optionalPassword": "Voliteľné heslo", "resolution": "Rozlíšenie", - "discardEditorChanges": "Naozaj chcete zahodiť vykonané zmeny?" + "discardEditorChanges": "Naozaj chcete zahodiť vykonané zmeny?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Obrázky", diff --git a/filebrowser/frontend/src/i18n/sv-se.json b/filebrowser/frontend/src/i18n/sv-se.json index 5542c7159f..a7cf261a2d 100644 --- a/filebrowser/frontend/src/i18n/sv-se.json +++ b/filebrowser/frontend/src/i18n/sv-se.json @@ -48,7 +48,11 @@ "saveChanges": "Spara ändringar", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Ladda ner fil", @@ -161,7 +165,17 @@ "uploadMessage": "Välj ett alternativ att ladda upp.", "optionalPassword": "Valfritt lösenord", "resolution": "Upplösning", - "discardEditorChanges": "Är du säker på att du vill förkasta ändringarna du gjort?" + "discardEditorChanges": "Är du säker på att du vill förkasta ändringarna du gjort?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Bilder", diff --git a/filebrowser/frontend/src/i18n/tr.json b/filebrowser/frontend/src/i18n/tr.json index 5ff72853e6..800c9e6493 100644 --- a/filebrowser/frontend/src/i18n/tr.json +++ b/filebrowser/frontend/src/i18n/tr.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Dosyayı indir", @@ -161,7 +165,17 @@ "uploadMessage": "Yüklemek için bir seçenek belirleyin.", "optionalPassword": "İsteğe bağlı şifre", "resolution": "Resolution", - "discardEditorChanges": "Are you sure you wish to discard the changes you've made?" + "discardEditorChanges": "Are you sure you wish to discard the changes you've made?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Görseller", diff --git a/filebrowser/frontend/src/i18n/uk.json b/filebrowser/frontend/src/i18n/uk.json index ccaf9a41f0..d9aa3fb4f1 100644 --- a/filebrowser/frontend/src/i18n/uk.json +++ b/filebrowser/frontend/src/i18n/uk.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Завантажити файл", @@ -161,7 +165,17 @@ "uploadMessage": "Виберіть варіант для вивантаження.", "optionalPassword": "Необов'язковий пароль", "resolution": "Розширення", - "discardEditorChanges": "Чи дійсно ви хочете скасувати поточні зміни?" + "discardEditorChanges": "Чи дійсно ви хочете скасувати поточні зміни?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Зображення", diff --git a/filebrowser/frontend/src/i18n/vi.json b/filebrowser/frontend/src/i18n/vi.json index ef3aeab580..ab73c0cb27 100644 --- a/filebrowser/frontend/src/i18n/vi.json +++ b/filebrowser/frontend/src/i18n/vi.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "Tải xuống tệp tin", @@ -161,7 +165,17 @@ "uploadMessage": "Chọn một tùy chọn để tải lên.", "optionalPassword": "Mật khẩu tùy chọn", "resolution": "Độ phân giải", - "discardEditorChanges": "Bạn có chắc chắn muốn hủy bỏ các thay đổi đã thực hiện không?" + "discardEditorChanges": "Bạn có chắc chắn muốn hủy bỏ các thay đổi đã thực hiện không?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "Hình ảnh", diff --git a/filebrowser/frontend/src/i18n/zh-cn.json b/filebrowser/frontend/src/i18n/zh-cn.json index 4a16eef972..816e1d8d82 100644 --- a/filebrowser/frontend/src/i18n/zh-cn.json +++ b/filebrowser/frontend/src/i18n/zh-cn.json @@ -48,7 +48,11 @@ "saveChanges": "保存更改", "editAsText": "以文本形式编辑", "increaseFontSize": "增大字体大小", - "decreaseFontSize": "减小字体大小" + "decreaseFontSize": "减小字体大小", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "下载文件", @@ -161,7 +165,17 @@ "uploadMessage": "选择上传选项。", "optionalPassword": "密码(选填,不填即无密码)", "resolution": "分辨率", - "discardEditorChanges": "你确定要放弃所做的更改吗?" + "discardEditorChanges": "你确定要放弃所做的更改吗?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "图像", diff --git a/filebrowser/frontend/src/i18n/zh-tw.json b/filebrowser/frontend/src/i18n/zh-tw.json index 61f264f38e..986b1d3b03 100644 --- a/filebrowser/frontend/src/i18n/zh-tw.json +++ b/filebrowser/frontend/src/i18n/zh-tw.json @@ -48,7 +48,11 @@ "saveChanges": "Save changes", "editAsText": "Edit as Text", "increaseFontSize": "Increase font size", - "decreaseFontSize": "Decrease font size" + "decreaseFontSize": "Decrease font size", + "overrideAll": "Replace all files in destination folder", + "skipAll": "Skip all conflicting files", + "renameAll": "Rename all files (create a copy)", + "singleDecision": "Decide for each conflicting file" }, "download": { "downloadFile": "下載檔案", @@ -161,7 +165,17 @@ "uploadMessage": "選擇上傳項。", "optionalPassword": "密碼(選填,不填即無密碼)", "resolution": "解析度", - "discardEditorChanges": "你確定要放棄所做的變更嗎?" + "discardEditorChanges": "你確定要放棄所做的變更嗎?", + "replaceOrSkip": "Replace or skip files", + "resolveConflict": "Which files do you want to keep?", + "singleConflictResolve": "If you select both versions, a number will be added to the name of the copied file.", + "fastConflictResolve": "The destination folder there are {count} files with same name.", + "uploadingFiles": "Uploading files", + "filesInOrigin": "Files in origin", + "filesInDest": "Files in destination", + "override": "Overwrite", + "skip": "Skip", + "forbiddenError": "Forbidden Error" }, "search": { "images": "影像", diff --git a/filebrowser/go.mod b/filebrowser/go.mod index 13fb76c920..baa22c355e 100644 --- a/filebrowser/go.mod +++ b/filebrowser/go.mod @@ -4,7 +4,7 @@ go 1.25 require ( github.com/asdine/storm/v3 v3.2.1 - github.com/asticode/go-astisub v0.38.0 + github.com/asticode/go-astisub v0.39.0 github.com/disintegration/imaging v1.6.2 github.com/dsoprea/go-exif/v3 v3.0.1 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 diff --git a/filebrowser/go.sum b/filebrowser/go.sum index 8a83f6ec41..356177314f 100644 --- a/filebrowser/go.sum +++ b/filebrowser/go.sum @@ -31,8 +31,8 @@ github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xbl github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/asticode/go-astikit v0.56.0 h1:DmD2p7YnvxiPdF0h+dRmos3bsejNEXbycENsY5JfBqw= github.com/asticode/go-astikit v0.56.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE= -github.com/asticode/go-astisub v0.38.0 h1:Qh3IO8Cotn0wwok5maid7xqsIJTwn2DtABT1UajKJaI= -github.com/asticode/go-astisub v0.38.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= +github.com/asticode/go-astisub v0.39.0 h1:j1/rFLRUH0TT2CW9YCtBek9lRdMp96oxaZm6vbgE96M= +github.com/asticode/go-astisub v0.39.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8= github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ= github.com/asticode/go-astits v1.13.0 h1:XOgkaadfZODnyZRR5Y0/DWkA9vrkLLPLeeOvDwfKZ1c= github.com/asticode/go-astits v1.13.0/go.mod h1:QSHmknZ51pf6KJdHKZHJTLlMegIrhega3LPWz3ND/iI= diff --git a/filebrowser/http/public.go b/filebrowser/http/public.go index 6dcdaff1a5..596a03c972 100644 --- a/filebrowser/http/public.go +++ b/filebrowser/http/public.go @@ -56,7 +56,7 @@ var withHashFile = func(fn handleFunc) handleFunc { filePath := "" if file.IsDir { - basePath = filepath.Dir(basePath) + basePath = filepath.Clean(link.Path) filePath = ifPath } diff --git a/lede/package/boot/uboot-envtools/files/ipq40xx b/lede/package/boot/uboot-envtools/files/ipq40xx index 30d2de7546..6c4184a32b 100644 --- a/lede/package/boot/uboot-envtools/files/ipq40xx +++ b/lede/package/boot/uboot-envtools/files/ipq40xx @@ -46,7 +46,8 @@ openmesh,a42|\ openmesh,a62|\ pakedge,wr-1|\ plasmacloud,pa1200|\ -plasmacloud,pa2200) +plasmacloud,pa2200|\ +thinkplus,fogpod800) ubootenv_add_uci_config "/dev/mtd5" "0x0" "0x10000" "0x10000" ;; aruba,ap-303) diff --git a/lede/package/firmware/ipq-wifi/Makefile b/lede/package/firmware/ipq-wifi/Makefile index cd043c08c7..d79d1a9ab4 100644 --- a/lede/package/firmware/ipq-wifi/Makefile +++ b/lede/package/firmware/ipq-wifi/Makefile @@ -64,6 +64,7 @@ ALLWIFIBOARDS:= \ qnap_301w \ redmi_ax5-jdcloud \ redmi_ax6 \ + thinkplus_fogpod800 \ wallys_dr40x9 \ xiaomi_ax3600 \ xiaomi_ax6000 \ @@ -199,6 +200,7 @@ $(eval $(call generate-ipq-wifi-package,qnap_301w,QNAP 301w)) $(eval $(call generate-ipq-wifi-package,prpl_haze,prpl Haze)) $(eval $(call generate-ipq-wifi-package,redmi_ax5-jdcloud,Redmi AX5 JDCloud)) $(eval $(call generate-ipq-wifi-package,redmi_ax6,Redmi AX6)) +$(eval $(call generate-ipq-wifi-package,thinkplus_fogpod800,ThinkPlus FogPOD800)) $(eval $(call generate-ipq-wifi-package,wallys_dr40x9,Wallys DR40X9)) $(eval $(call generate-ipq-wifi-package,xiaomi_ax3600,Xiaomi AX3600)) $(eval $(call generate-ipq-wifi-package,xiaomi_ax6000,Xiaomi AX6000)) diff --git a/lede/package/firmware/ipq-wifi/src/board-thinkplus_fogpod800.qca4019 b/lede/package/firmware/ipq-wifi/src/board-thinkplus_fogpod800.qca4019 new file mode 100644 index 0000000000..1e56b4c9a2 Binary files /dev/null and b/lede/package/firmware/ipq-wifi/src/board-thinkplus_fogpod800.qca4019 differ diff --git a/lede/target/linux/ipq40xx/base-files/etc/board.d/02_network b/lede/target/linux/ipq40xx/base-files/etc/board.d/02_network index 8644624873..703cd6a503 100644 --- a/lede/target/linux/ipq40xx/base-files/etc/board.d/02_network +++ b/lede/target/linux/ipq40xx/base-files/etc/board.d/02_network @@ -22,6 +22,16 @@ ipq40xx_setup_interfaces() plasmacloud,pa2200) ucidef_set_interfaces_lan_wan "eth0" "eth1" ;; + alibaba,ap4220-48m|\ + alibaba,ap4220-128m|\ + asus,map-ac2200|\ + cilab,meshpoint-one|\ + edgecore,ecw5211|\ + edgecore,oap100|\ + openmesh,a42|\ + openmesh,a62) + ucidef_set_interfaces_lan_wan "eth1" "eth0" + ;; aruba,ap-303|\ aruba,ap-365|\ avm,fritzrepeater-1200|\ @@ -42,20 +52,6 @@ ipq40xx_setup_interfaces() ucidef_add_switch "switch0" \ "0u@eth0" "2:lan:1" "3:lan:2" "4:lan:3" "0u@eth1" "5:wan" ;; - alibaba,ap4220-48m|\ - alibaba,ap4220-128m|\ - asus,map-ac2200|\ - cilab,meshpoint-one|\ - edgecore,ecw5211|\ - edgecore,oap100|\ - openmesh,a42|\ - openmesh,a62) - ucidef_set_interfaces_lan_wan "eth1" "eth0" - ;; - mikrotik,cap-ac) - ucidef_add_switch "switch0" \ - "0t@eth0" "4:lan" "5:wan" - ;; asus,rt-ac42u|\ asus,rt-ac58u|\ mikrotik,hap-ac2|\ @@ -70,16 +66,12 @@ ipq40xx_setup_interfaces() avm,fritzbox-4040|\ linksys,ea6350v3|\ linksys,ea8300|\ + thinkplus,fogpod800|\ yyets,le1) ucidef_set_interfaces_lan_wan "eth0" "eth1" ucidef_add_switch "switch0" \ "0u@eth0" "1:lan" "2:lan" "3:lan" "4:lan" ;; - linksys,mr8300) - ucidef_set_interfaces_lan_wan "eth0" "eth1" - ucidef_add_switch "switch0" \ - "0u@eth0" "1:lan" "2:lan" "3:lan" "4:lan" "0u@eth1" "5:wan" - ;; avm,fritzbox-7530) ucidef_add_switch "switch0" \ "0u@eth0" "1:lan:4" "2:lan:3" "3:lan:2" "4:lan:1" @@ -88,11 +80,6 @@ ipq40xx_setup_interfaces() ucidef_add_switch "switch0" \ "0u@eth0" "4:lan:1" "5:lan:2" ;; - compex,wpj419|\ - compex,wpj428|\ - engenius,eap2200) - ucidef_set_interface_lan "eth0 eth1" - ;; buffalo,wtr-m2133hp) ucidef_set_interfaces_lan_wan "eth0" "eth1" ucidef_add_switch "switch0" \ @@ -103,6 +90,11 @@ ipq40xx_setup_interfaces() ucidef_add_switch "switch0" \ "0u@eth0" "3:lan" "4:lan" ;; + compex,wpj419|\ + compex,wpj428|\ + engenius,eap2200) + ucidef_set_interface_lan "eth0 eth1" + ;; devolo,magic-2-wifi-next) ucidef_set_interface_lan "eth0 eth1 eth2" ;; @@ -123,6 +115,15 @@ ipq40xx_setup_interfaces() ucidef_add_switch "switch0" \ "0u@eth0" "1:lan" "2:lan" "3:lan" "5:lan" "0u@eth1" "4:wan" ;; + linksys,mr8300) + ucidef_set_interfaces_lan_wan "eth0" "eth1" + ucidef_add_switch "switch0" \ + "0u@eth0" "1:lan" "2:lan" "3:lan" "4:lan" "0u@eth1" "5:wan" + ;; + mikrotik,cap-ac) + ucidef_add_switch "switch0" \ + "0t@eth0" "4:lan" "5:wan" + ;; mobipromo,cm520-79f) ucidef_add_switch "switch0" \ "0u@eth0" "3:lan:2" "4:lan:1" @@ -136,14 +137,14 @@ ipq40xx_setup_interfaces() "0u@eth0" "2:lan" "3:lan" "4:lan" ucidef_set_interface_wan "eth1" ;; - qxwlan,e2600ac-c1 |\ + qxwlan,e2600ac-c1|\ qxwlan,e2600ac-c2) ucidef_set_interfaces_lan_wan "eth0" "eth1" ucidef_add_switch "switch0" \ "0u@eth0" "3:lan" "4:lan" "0u@eth1" "5:wan" ;; - unielec,u4019-32m |\ - tel,x1pro) + tel,x1pro|\ + unielec,u4019-32m) ucidef_set_interfaces_lan_wan "eth0" "eth1" ucidef_add_switch "switch0" \ "0u@eth0" "1:lan" "2:lan" "3:lan" "4:lan" "0u@eth1" "5:wan" @@ -172,15 +173,15 @@ ipq40xx_setup_macs() local label_mac="" case "$board" in + 8dev,habanero-dvk) + label_mac=$(mtd_get_mac_binary "ART" 0x1006) + ;; alibaba,ap4220-48m|\ alibaba,ap4220-128m) wan_mac=$(mtd_get_mac_text product_info 0x40) lan_mac=$(macaddr_add "$wan_mac" 1) label_mac="$wan_mac" ;; - 8dev,habanero-dvk) - label_mac=$(mtd_get_mac_binary "ART" 0x1006) - ;; asus,rt-ac58u) wan_mac=$(mtd_get_mac_binary_ubi Factory 0x1006) lan_mac=$(mtd_get_mac_binary_ubi Factory 0x5006) @@ -230,6 +231,11 @@ ipq40xx_setup_macs() lan_mac=$(cat /sys/firmware/mikrotik/hard_config/mac_base) label_mac="$lan_mac" ;; + thinkplus,fogpod800) + wan_mac=$(mtd_get_mac_binary "0:ART" 0x0) + lan_mac=$(mtd_get_mac_binary "0:ART" 0x6) + label_mac=$wan_mac + ;; esac [ -n "$lan_mac" ] && ucidef_set_interface_macaddr "lan" $lan_mac diff --git a/lede/target/linux/ipq40xx/base-files/lib/upgrade/platform.sh b/lede/target/linux/ipq40xx/base-files/lib/upgrade/platform.sh index 83aff67d31..06104a4bc0 100644 --- a/lede/target/linux/ipq40xx/base-files/lib/upgrade/platform.sh +++ b/lede/target/linux/ipq40xx/base-files/lib/upgrade/platform.sh @@ -234,7 +234,8 @@ platform_do_upgrade() { netgear,wac510 |\ p2w,r619ac-64m |\ p2w,r619ac-128m |\ - qxwlan,e2600ac-c2) + qxwlan,e2600ac-c2 |\ + thinkplus,fogpod800) nand_do_upgrade "$1" ;; glinet,gl-b2200) diff --git a/lede/target/linux/ipq40xx/files/arch/arm/boot/dts/qcom-ipq4028-fogpod800.dts b/lede/target/linux/ipq40xx/files/arch/arm/boot/dts/qcom-ipq4028-fogpod800.dts new file mode 100644 index 0000000000..e93be2283c --- /dev/null +++ b/lede/target/linux/ipq40xx/files/arch/arm/boot/dts/qcom-ipq4028-fogpod800.dts @@ -0,0 +1,315 @@ +// SPDX-License-Identifier: GPL-2.0-or-later OR MIT + +#include "qcom-ipq4019.dtsi" +#include +#include +#include + +/ { + model = "ThinkPlus FogPOD800"; + compatible = "thinkplus,fogpod800"; + + aliases { + led-boot = &led_power; + led-failsafe = &led_power; + led-running = &led_power; + led-upgrade = &led_power; + }; + + chosen { + bootargs-append = " ubi.mtd=rootfs root=/dev/ubiblock0_1"; + }; + + soc { + rng@22000 { + status = "okay"; + }; + + mdio@90000 { + status = "okay"; + }; + + ess-psgmii@98000 { + status = "okay"; + }; + + tcsr@1949000 { + compatible = "qcom,tcsr"; + reg = <0x1949000 0x100>; + qcom,wifi_glb_cfg = ; + }; + + tcsr@194b000 { + compatible = "qcom,tcsr"; + reg = <0x194b000 0x100>; + qcom,usb-hsphy-mode-select = ; + }; + + ess_tcsr@1953000 { + compatible = "qcom,tcsr"; + reg = <0x1953000 0x1000>; + qcom,ess-interface-select = ; + }; + + tcsr@1957000 { + compatible = "qcom,tcsr"; + reg = <0x1957000 0x100>; + qcom,wifi_noc_memtype_m0_m2 = ; + }; + + usb2@60f8800 { + status = "okay"; + + dwc3@6000000 { + #address-cells = <1>; + #size-cells = <0>; + + usb2_port1: port@1 { + reg = <1>; + #trigger-source-cells = <0>; + }; + }; + }; + + usb3@8af8800 { + status = "okay"; + + dwc3@8a00000 { + #address-cells = <1>; + #size-cells = <0>; + + usb3_port1: port@1 { + reg = <1>; + #trigger-source-cells = <0>; + }; + + usb3_port2: port@2 { + reg = <2>; + #trigger-source-cells = <0>; + }; + }; + }; + + crypto@8e3a000 { + status = "okay"; + }; + + watchdog@b017000 { + status = "okay"; + }; + + ess-switch@c000000 { + status = "okay"; + }; + + edma@c080000 { + status = "okay"; + }; + }; + + keys { + compatible = "gpio-keys"; + + reset { + label = "reset"; + gpios = <&tlmm 63 GPIO_ACTIVE_LOW>; + linux,code = ; + }; + }; + + leds { + compatible = "gpio-leds"; + + led_power: status { + label = "red:status"; + gpios = <&tlmm 2 GPIO_ACTIVE_LOW>; + }; + + wan { + label = "green:wan"; + gpios = <&tlmm 3 GPIO_ACTIVE_LOW>; + linux,default-trigger = "90000.mdio-1:04:link"; + }; + + wlan5g { + label = "blue:wlan5g"; + gpios = <&tlmm 4 GPIO_ACTIVE_LOW>; + linux,default-trigger = "phy1tpt"; + }; + + usb { + label = "blue:usb"; + gpios = <&tlmm 5 GPIO_ACTIVE_LOW>; + linux,default-trigger = "usbport"; + trigger-sources = <&usb2_port1>, <&usb3_port1>, <&usb3_port2>; + }; + + wlan2g { + label = "blue:wlan2g"; + gpios = <&tlmm 58 GPIO_ACTIVE_LOW>; + linux,default-trigger = "phy0tpt"; + }; + }; +}; + +&cryptobam { + status = "okay"; +}; + +&blsp_dma { + status = "okay"; +}; + +&tlmm { + serial0_pins: serial0_pinmux { + mux { + pins = "gpio60", "gpio61"; + function = "blsp_uart0"; + bias-disable; + }; + }; + + spi0_pins: spi0_pinmux { + mux { + function = "blsp_spi0"; + pins = "gpio55", "gpio56", "gpio57"; + drive-strength = <12>; + bias-disable; + }; + + mux_cs { + function = "gpio"; + pins = "gpio54", "gpio59"; + drive-strength = <2>; + bias-disable; + output-high; + }; + }; +}; + +&blsp1_spi1 { + status = "okay"; + + pinctrl-0 = <&spi0_pins>; + pinctrl-names = "default"; + cs-gpios = <&tlmm 54 GPIO_ACTIVE_HIGH>, <&tlmm 59 GPIO_ACTIVE_HIGH>; + + flash@0 { + compatible = "jedec,spi-nor"; + reg = <0>; + spi-max-frequency = <24000000>; + + partitions { + compatible = "fixed-partitions"; + #address-cells = <1>; + #size-cells = <1>; + + partition@0 { + label = "0:SBL1"; + reg = <0x0 0x40000>; + read-only; + }; + + partition@40000 { + label = "0:MIBIB"; + reg = <0x40000 0x20000>; + read-only; + }; + + partition@60000 { + label = "0:QSEE"; + reg = <0x60000 0x60000>; + read-only; + }; + + partition@c0000 { + label = "0:CDT"; + reg = <0xc0000 0x10000>; + read-only; + }; + + partition@d0000 { + label = "0:DDRPARAMS"; + reg = <0xd0000 0x10000>; + read-only; + }; + + partition@e0000 { + label = "0:APPSBLENV"; + reg = <0xe0000 0x10000>; + }; + + partition@f0000 { + label = "0:APPSBL"; + reg = <0xf0000 0x80000>; + read-only; + }; + + partition@170000 { + label = "0:ART"; + reg = <0x170000 0x10000>; + read-only; + compatible = "nvmem-cells"; + #address-cells = <1>; + #size-cells = <1>; + + precal_art_1000: precal@1000 { + reg = <0x1000 0x2f20>; + }; + + precal_art_5000: precal@5000 { + reg = <0x5000 0x2f20>; + }; + }; + }; + }; + + nand@1 { + compatible = "spi-nand"; + reg = <1>; + spi-max-frequency = <24000000>; + + partitions { + compatible = "fixed-partitions"; + #address-cells = <1>; + #size-cells = <1>; + + partition@0 { + label = "rootfs"; + reg = <0x0 0x8000000>; + }; + }; + }; +}; + +&blsp1_uart1 { + pinctrl-0 = <&serial0_pins>; + pinctrl-names = "default"; + status = "okay"; +}; + +&usb2_hs_phy { + status = "okay"; +}; + +&usb3_ss_phy { + status = "okay"; +}; + +&usb3_hs_phy { + status = "okay"; +}; + +&wifi0 { + status = "okay"; + nvmem-cell-names = "pre-calibration"; + nvmem-cells = <&precal_art_1000>; + qcom,ath10k-calibration-variant = "ThinkPlus FogPOD800"; +}; + +&wifi1 { + status = "okay"; + nvmem-cell-names = "pre-calibration"; + nvmem-cells = <&precal_art_5000>; + qcom,ath10k-calibration-variant = "ThinkPlus FogPOD800"; +}; diff --git a/lede/target/linux/ipq40xx/image/generic.mk b/lede/target/linux/ipq40xx/image/generic.mk index 00209b418f..c3bdf3d2a3 100644 --- a/lede/target/linux/ipq40xx/image/generic.mk +++ b/lede/target/linux/ipq40xx/image/generic.mk @@ -997,6 +997,20 @@ define Device/tel_x1pro endef TARGET_DEVICES += tel_x1pro +define Device/thinkplus_fogpod800 + $(call Device/FitImage) + $(call Device/UbiFit) + DEVICE_VENDOR := ThinkPlus + DEVICE_MODEL := FogPOD800 + SOC := qcom-ipq4028 + KERNEL_SIZE := 4096k + DEVICE_DTS_CONFIG := config@ap.dk01.1-c2 + BLOCKSIZE := 128k + PAGESIZE := 2048 + DEVICE_PACKAGES := kmod-usb-ledtrig-usbport ipq-wifi-thinkplus_fogpod800 +endef +TARGET_DEVICES += thinkplus_fogpod800 + define Device/unielec_u4019-32m $(call Device/FitImage) DEVICE_VENDOR := Unielec diff --git a/lede/target/linux/ipq40xx/patches-5.10/901-arm-boot-add-dts-files.patch b/lede/target/linux/ipq40xx/patches-5.10/901-arm-boot-add-dts-files.patch index 0f276aa1be..31947d5c5c 100644 --- a/lede/target/linux/ipq40xx/patches-5.10/901-arm-boot-add-dts-files.patch +++ b/lede/target/linux/ipq40xx/patches-5.10/901-arm-boot-add-dts-files.patch @@ -10,7 +10,7 @@ Signed-off-by: John Crispin --- a/arch/arm/boot/dts/Makefile +++ b/arch/arm/boot/dts/Makefile -@@ -904,11 +904,79 @@ dtb-$(CONFIG_ARCH_QCOM) += \ +@@ -904,11 +904,80 @@ dtb-$(CONFIG_ARCH_QCOM) += \ qcom-apq8074-dragonboard.dtb \ qcom-apq8084-ifc6540.dtb \ qcom-apq8084-mtp.dtb \ @@ -79,6 +79,7 @@ Signed-off-by: John Crispin + qcom-ipq4019-u4019-32m.dtb \ + qcom-ipq4019-wpj419.dtb \ + qcom-ipq4019-wtr-m2133hp.dtb \ ++ qcom-ipq4028-fogpod800.dtb \ + qcom-ipq4028-wpj428.dtb \ + qcom-ipq4029-ap-303.dtb \ + qcom-ipq4029-ap-303h.dtb \ diff --git a/mieru/apis/trafficpattern/config_test.go b/mieru/apis/trafficpattern/config_test.go index ad60c612cf..fedaac8dd6 100644 --- a/mieru/apis/trafficpattern/config_test.go +++ b/mieru/apis/trafficpattern/config_test.go @@ -265,6 +265,16 @@ func TestEncodeDecode(t *testing.T) { } } +func TestDecodeEmptyString(t *testing.T) { + trafficPattern, err := Decode("") + if err != nil { + t.Fatalf("Failed to decode empty string: %v", err) + } + if trafficPattern == nil { + t.Errorf("Returned nil traffic pattern") + } +} + func TestValidate(t *testing.T) { cases := []struct { name string diff --git a/mieru/pkg/cli/client.go b/mieru/pkg/cli/client.go index 5d6ce9a828..3191b49b78 100644 --- a/mieru/pkg/cli/client.go +++ b/mieru/pkg/cli/client.go @@ -136,6 +136,18 @@ func RegisterClientCommands() { }, clientExportTrafficPatternFunc, ) + RegisterCallback( + []string{"", "explain", "traffic-pattern"}, + func(s []string) error { + if len(s) < 4 { + return fmt.Errorf("usage: mieru explain traffic-pattern . No string is provided") + } else if len(s) > 4 { + return fmt.Errorf("usage: mieru explain traffic-pattern . More than 1 string is provided") + } + return nil + }, + explainTrafficPatternFunc, + ) RegisterCallback( []string{"", "import", "config"}, func(s []string) error { @@ -329,6 +341,10 @@ var clientHelpFunc = func(s []string) error { cmd: "export traffic-pattern", help: []string{"Export traffic pattern as an encoded base64 string."}, }, + { + cmd: "explain traffic-pattern ", + help: []string{"Decode and explain a traffic pattern from an encoded base64 string."}, + }, { cmd: "delete profile ", help: []string{"Delete an inactive client configuration profile."}, diff --git a/mieru/pkg/cli/server.go b/mieru/pkg/cli/server.go index da67cc5332..1b692a437d 100644 --- a/mieru/pkg/cli/server.go +++ b/mieru/pkg/cli/server.go @@ -125,6 +125,18 @@ func RegisterServerCommands() { }, serverExportTrafficPatternFunc, ) + RegisterCallback( + []string{"", "explain", "traffic-pattern"}, + func(s []string) error { + if len(s) < 4 { + return fmt.Errorf("usage: mita explain traffic-pattern . No string is provided") + } else if len(s) > 4 { + return fmt.Errorf("usage: mita explain traffic-pattern . More than 1 string is provided") + } + return nil + }, + explainTrafficPatternFunc, + ) RegisterCallback( []string{"", "delete", "user"}, func(s []string) error { @@ -274,6 +286,10 @@ var serverHelpFunc = func(s []string) error { cmd: "export traffic-pattern", help: []string{"Export traffic pattern as an encoded base64 string."}, }, + { + cmd: "explain traffic-pattern ", + help: []string{"Decode and explain a traffic pattern from an encoded base64 string."}, + }, { cmd: "delete user ", help: []string{"Delete a user from server configuration."}, diff --git a/mieru/pkg/cli/shared.go b/mieru/pkg/cli/shared.go index 25550ef057..58bb9fe220 100644 --- a/mieru/pkg/cli/shared.go +++ b/mieru/pkg/cli/shared.go @@ -21,7 +21,9 @@ import ( "strings" "time" + "github.com/enfein/mieru/v3/apis/trafficpattern" "github.com/enfein/mieru/v3/pkg/appctl/appctlpb" + "github.com/enfein/mieru/v3/pkg/common" "github.com/enfein/mieru/v3/pkg/log" "github.com/enfein/mieru/v3/pkg/mathext" "github.com/enfein/mieru/v3/pkg/version" @@ -41,6 +43,19 @@ var describeBuildFunc = func(_ []string) error { return nil } +var explainTrafficPatternFunc = func(s []string) error { + pattern, err := trafficpattern.Decode(s[3]) + if err != nil { + return err + } + jsonBytes, err := common.MarshalJSON(pattern) + if err != nil { + return fmt.Errorf("common.MarshalJSON() failed: %w", err) + } + log.Infof("%s", string(jsonBytes)) + return nil +} + func printSessionInfoList(info *appctlpb.SessionInfoList) { header := []string{ "SessionID", diff --git a/mieru/test/deploy/basic/test_mix_udp_associate.sh b/mieru/test/deploy/basic/test_mix_udp_associate.sh index c0c50fb7a6..960d048b81 100755 --- a/mieru/test/deploy/basic/test_mix_udp_associate.sh +++ b/mieru/test/deploy/basic/test_mix_udp_associate.sh @@ -38,8 +38,10 @@ echo "mieru server config:" ./mita describe config echo "mieru server effective traffic pattern:" ./mita describe effective-traffic-pattern +encoded_server_traffic_pattern=$(./mita export traffic-pattern) echo "mieru server encoded traffic pattern:" -./mita export traffic-pattern +echo ${encoded_server_traffic_pattern} +./mita explain traffic-pattern $(echo ${encoded_server_traffic_pattern}) sleep 1 # Start mieru server proxy. @@ -76,8 +78,10 @@ echo "mieru client config after import:" ./mieru describe config echo "mieru client effective traffic pattern:" ./mieru describe effective-traffic-pattern +encoded_client_traffic_pattern=$(./mieru export traffic-pattern) echo "mieru client encoded traffic pattern:" -./mieru export traffic-pattern +echo ${encoded_client_traffic_pattern} +./mieru explain traffic-pattern $(echo ${encoded_client_traffic_pattern}) sleep 1 # Start mieru client. diff --git a/openwrt-packages/filebrowser/Makefile b/openwrt-packages/filebrowser/Makefile index f683141c0f..d4bf077827 100644 --- a/openwrt-packages/filebrowser/Makefile +++ b/openwrt-packages/filebrowser/Makefile @@ -5,12 +5,12 @@ include $(TOPDIR)/rules.mk PKG_NAME:=filebrowser -PKG_VERSION:=2.60.0 +PKG_VERSION:=2.61.0 PKG_RELEASE:=1 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/filebrowser/filebrowser/tar.gz/v${PKG_VERSION}? -PKG_HASH:=6ab1f5bfb68f13799e58db304361d8bbf7d2a42e893f4c1873cb6c1688912df0 +PKG_HASH:=9c85502cbb28b3812aeec921fb8fe51694d5a837aa4415c026c327522492ba05 PKG_LICENSE:=Apache-2.0 PKG_LICENSE_FILES:=LICENSE diff --git a/openwrt-packages/luci-app-ddns-go/Makefile b/openwrt-packages/luci-app-ddns-go/Makefile index b9e91041cd..7c9f013d3f 100644 --- a/openwrt-packages/luci-app-ddns-go/Makefile +++ b/openwrt-packages/luci-app-ddns-go/Makefile @@ -7,8 +7,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-ddns-go -PKG_VERSION:=1.6.5 -PKG_RELEASE:=20260123 +PKG_VERSION:=1.6.6 +PKG_RELEASE:=20260228 PKG_MAINTAINER:=sirpdboy PKG_CONFIG_DEPENDS:= diff --git a/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/config.js b/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/config.js index adf86c838a..d0a60c8e41 100644 --- a/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/config.js +++ b/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/config.js @@ -113,29 +113,30 @@ return view.extend({ uci.load('ddns-go') ]); }, - handleResetPassword: async function () { try { ui.showModal(_('Resetting Password'), [ - E('p', { 'class': 'spinning' }, _('Resetting admin password, please wait...')) + E('p', { 'class': 'spinning' }, _('Resetting admin username and password, please wait...')) ]); - const result = await fs.exec('/usr/bin/ddns-go', ['-resetPassword', 'admin12345', '-c', '/etc/ddns-go/ddns-go-config.yaml']); + const configFile = '/etc/ddns-go/ddns-go-config.yaml'; + const readResult = await fs.read(configFile); + if (readResult && readResult.trim() !== '') { + let configContent = readResult; + configContent = configContent.replace(/(username:\s*).*/g, '$1admin'); + + if (!configContent.includes('user:')) { + configContent += '\nuser:\n username: admin\n password: $2a$10$G1xO1cVUYtSpPYwV/Jk3l.u7PxLUxo03wntWG6VA9BxAftNWfZEhK'; + } + + await fs.write(configFile, configContent); + } ui.hideModal(); - const output = (result.stdout + result.stderr).trim(); - - let success = false; - let message = ''; - if (result.code === 0) { - - - message = _('Password reset successfully to admin12345'); - - ui.showModal(_('Password Reset Successful'), [ - E('p', _('Reset User:admin ,Reset password: admin12345')), + ui.showModal(_('Username and Password Reset Successful'), [ + E('p', _('Username: admin, Password: admin12345')), E('p', _('You need to restart DDNS-Go service for the changes to take effect.')), E('div', { 'class': 'right' }, [ E('button', { @@ -153,15 +154,33 @@ return view.extend({ ]) ]); } else { - alert(_('Reset may have failed:') + '\n' + output); + ui.showModal(_('Partial Reset'), [ + E('p', _('DDNS-Go command reset may have failed, but configuration file has been updated.')), + E('p', _('Username: admin, Password: admin12345')), + E('p', _('You may need to restart DDNS-Go service manually.')), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button cbi-button-positive', + 'click': ui.createHandlerFn(this, function() { + ui.hideModal(); + this.handleRestartService(); + }) + }, _('Restart Service Now')), + ' ', + E('button', { + 'class': 'btn cbi-button cbi-button-neutral', + 'click': ui.hideModal + }, _('Close')) + ]) + ]); } } catch (error) { ui.hideModal(); - console.error('Reset password failed:', error); - alert(_('ERROR:') + '\n' + _('Reset password failed:') + '\n' + error.message); + //console.error('Reset username/password failed:', error); + alert(_('ERROR:') + '\n' + _('Resetusername/ password failed:') + '\n' + error.message); } - }, +}, handleRestartService: async function() { try { @@ -310,7 +329,7 @@ return view.extend({ o.default = '60'; o = s.option(form.Button, '_newpassword', _('Reset account password')); - o.inputtitle = _('ResetPassword'); + o.inputtitle = _('Reset'); o.inputstyle = 'apply'; o.onclick = L.bind(this.handleResetPassword, this, data); diff --git a/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/ddns-go.js b/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/ddns-go.js index 328170d1b0..cca9e4e7a7 100644 --- a/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/ddns-go.js +++ b/openwrt-packages/luci-app-ddns-go/htdocs/luci-static/resources/view/ddns-go/ddns-go.js @@ -1,4 +1,4 @@ -/* Copyright (C) 2021-2025 sirpdboy herboy2008@gmail.com https://github.com/sirpdboy/luci-app-ddns-go */ +/* Copyright (C) 2021-2026 sirpdboy herboy2008@gmail.com https://github.com/sirpdboy/luci-app-ddns-go */ 'use strict'; 'require view'; diff --git a/openwrt-packages/luci-app-ddns-go/po/zh_Hans/ddns-go.po b/openwrt-packages/luci-app-ddns-go/po/zh_Hans/ddns-go.po index ecefebae17..7cf3aaac1d 100644 --- a/openwrt-packages/luci-app-ddns-go/po/zh_Hans/ddns-go.po +++ b/openwrt-packages/luci-app-ddns-go/po/zh_Hans/ddns-go.po @@ -95,7 +95,7 @@ msgid "Update status unknown" msgstr "更新状态未知" msgid "Reset account password" -msgstr "重置登录密码" +msgstr "重置帐号密码" msgid "ResetPassword" msgstr "重置密码" @@ -103,18 +103,33 @@ msgstr "重置密码" msgid "SUCCESS:" msgstr "完成执行:" -msgid "Password reset successfully to admin12345" -msgstr "成功重置密码admin12345" +msgid "Resetting admin username and password, please wait...5" +msgstr "重置用户名与密码,请稍等" -msgid "Password Reset Successful" -msgstr "重置密码成功" +msgid "Username and Password Reset Successful" +msgstr "用户名和密码重置成功" -msgid "Reset User:admin ,Reset password: admin12345" +msgid "Reset username to: admin, Reset password to: admin12345" msgstr "重置用户名:admin 重置密码:admin12345" +msgid "Username: admin, Password: admin12345" +msgstr "用户名:admin 密码:admin12345" + msgid "You need to restart DDNS-Go service for the changes to take effect." msgstr "需要重启DDNS-GO服务更改才生效" +msgid "You may need to restart DDNS-Go service manually." +msgstr "必须要重启DDNS-GO服务." + +msgid "DDNS-Go command reset may have failed, but configuration file has been updated." +msgstr "ddns-go重置命令失败,尝试通过直接修改配置文件来重置" + +msgid "Reset username/password" +msgstr "重置用户名/密码" + +msgid "DDNS-Go service restarted successfully" +msgstr "DDNS-GO服务重启成功" + msgid "Restart Service Now" msgstr "立刻重启服务" @@ -122,4 +137,4 @@ msgid "Restart Later" msgstr "稍后重启" msgid "Due to browser security policies, the DDNS-GO interface https cannot be embedded directly." -msgstr "由于浏览器安全策略,DDNS-GO接口https不能直接嵌入。" +msgstr "由于浏览器安全策略,DDNS-GO接口https不能直接嵌入。" \ No newline at end of file diff --git a/openwrt-packages/luci-app-ddns-go/root/usr/share/luci/menu.d/luci-app-ddns-go.json b/openwrt-packages/luci-app-ddns-go/root/usr/share/luci/menu.d/luci-app-ddns-go.json index 8f2c668c8f..200856d6b4 100644 --- a/openwrt-packages/luci-app-ddns-go/root/usr/share/luci/menu.d/luci-app-ddns-go.json +++ b/openwrt-packages/luci-app-ddns-go/root/usr/share/luci/menu.d/luci-app-ddns-go.json @@ -13,8 +13,8 @@ "admin/services/ddns-go/ddns-go": { "title": "DDNS-GO Control panel", - "order": 10, - "action": { + "order": 10, + "action": { "type": "view", "path": "ddns-go/ddns-go" } @@ -27,7 +27,7 @@ "path": "ddns-go/config" } }, - + "admin/services/ddns-go/log": { "title": "Log", "order": 30, diff --git a/openwrt-packages/luci-app-ddns-go/root/usr/share/rpcd/acl.d/luci-app-ddns-go.json b/openwrt-packages/luci-app-ddns-go/root/usr/share/rpcd/acl.d/luci-app-ddns-go.json index 4fbd4a529e..7cc44459ab 100644 --- a/openwrt-packages/luci-app-ddns-go/root/usr/share/rpcd/acl.d/luci-app-ddns-go.json +++ b/openwrt-packages/luci-app-ddns-go/root/usr/share/rpcd/acl.d/luci-app-ddns-go.json @@ -2,7 +2,7 @@ "luci-app-ddns-go": { "description": "Grant UCI access for luci-app-ddns-go", "read": { - "uci": ["*"], + "uci": [ "ddns-go" ], "file": { "/etc/init.d/ddns-go": ["exec"], "/usr/libexec/ddns-go-call": ["exec"], @@ -10,6 +10,7 @@ "/bin/pidof": ["exec"], "/bin/ps": ["exec"], "/bin/ash": ["exec"], + "/usr/bin/ddns-go": ["exec"], "/etc/ddns-go/ddns-go-config.yaml": ["read"], "/var/log/ddns-go.log": ["read"] }, @@ -17,7 +18,6 @@ "rc": ["*"], "service": ["list"], "luci.ddns-go": ["*"], - "network.interface.*": ["status"], "network": ["*"] } }, @@ -28,7 +28,7 @@ "file": { "/etc/ddns-go/ddns-go-config.yaml": ["write"] }, - "uci": ["*"] + "uci": ["ddns-go"] } } -} +} \ No newline at end of file diff --git a/openwrt-packages/luci-app-lucky/lucky/Makefile b/openwrt-packages/luci-app-lucky/lucky/Makefile index 8474ab8b8e..3463ff5177 100644 --- a/openwrt-packages/luci-app-lucky/lucky/Makefile +++ b/openwrt-packages/luci-app-lucky/lucky/Makefile @@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=lucky -PKG_VERSION:=2.26.1 +PKG_VERSION:=2.26.2 PKG_RELEASE:=1 PKGARCH:=all diff --git a/openwrt-packages/luci-app-partexp/luci-app-partexp/Makefile b/openwrt-packages/luci-app-partexp/luci-app-partexp/Makefile index 1d5ada132a..d112ef67bf 100644 --- a/openwrt-packages/luci-app-partexp/luci-app-partexp/Makefile +++ b/openwrt-packages/luci-app-partexp/luci-app-partexp/Makefile @@ -10,8 +10,8 @@ PKG_NAME:=luci-app-partexp LUCI_TITLE:=LuCI Support for Automatic Partition Mount LUCI_PKGARCH:=all LUCI_DEPENDS:=+fdisk +block-mount +bc +blkid +parted +btrfs-progs +losetup +resize2fs +e2fsprogs +f2fs-tools +kmod-loop -PKG_VERSION:=2.0.2 -PKG_RELEASE:=20251221 +PKG_VERSION:=2.0.3 +PKG_RELEASE:=20260228 PKG_LICENSE:=Apache-2.0 PKG_MAINTAINER:=Sirpdboy diff --git a/openwrt-packages/luci-app-partexp/luci-app-partexp/htdocs/luci-static/resources/view/partexp.js b/openwrt-packages/luci-app-partexp/luci-app-partexp/htdocs/luci-static/resources/view/partexp.js index 79e5211045..99c9a9555a 100644 --- a/openwrt-packages/luci-app-partexp/luci-app-partexp/htdocs/luci-static/resources/view/partexp.js +++ b/openwrt-packages/luci-app-partexp/luci-app-partexp/htdocs/luci-static/resources/view/partexp.js @@ -266,32 +266,74 @@ return view.extend({ this.updateFormVisibility(); } }, - - // 加载设备列表 - loadDevices: function() { - var self = this; - + +loadDevices: function() { + var self = this; + + if (self.dom.targetDisk) { + var loadingOption = document.createElement('option'); + loadingOption.value = ''; + loadingOption.textContent = _('Loading devices...'); + loadingOption.disabled = true; + loadingOption.selected = true; + self.dom.targetDisk.innerHTML = ''; + self.dom.targetDisk.appendChild(loadingOption); + } + function loadDevicesWithRetry(retryCount = 0) { callPartExpGetDevices().then(function(response) { - if (!response || !response.devices || response.devices.length === 0) { - return; + if (!response) { + throw new Error('Empty response'); } - - // 清空设备列表 if (self.dom.targetDisk) { self.dom.targetDisk.innerHTML = ''; - - // 添加设备选项 - response.devices.forEach(function(device) { - var option = document.createElement('option'); - option.value = device.name; - option.textContent = device.name + ' (' + device.dev + ', ' + device.size + ' MB)'; - self.dom.targetDisk.appendChild(option); - }); + if (response.devices && response.devices.length > 0) { + response.devices.forEach(function(device) { + var option = document.createElement('option'); + option.value = device.name; + option.textContent = device.name + ' (' + device.dev + ', ' + device.size + ' MB)'; + self.dom.targetDisk.appendChild(option); + }); + } else { + + var noDeviceOption = document.createElement('option'); + noDeviceOption.value = ''; + noDeviceOption.textContent = _('no find device'); + noDeviceOption.disabled = true; + noDeviceOption.selected = true; + self.dom.targetDisk.appendChild(noDeviceOption); + } } }).catch(function(error) { console.error('Failed to load devices:', error); + + if (retryCount < 3) { + setTimeout(function() { + loadDevicesWithRetry(retryCount + 1); + }, 1000 * (retryCount + 1)); + } else { + if (self.dom.targetDisk) { + self.dom.targetDisk.innerHTML = ''; + var errorOption = document.createElement('option'); + errorOption.value = ''; + errorOption.textContent = _('load error'); + errorOption.disabled = true; + errorOption.selected = true; + self.dom.targetDisk.appendChild(errorOption); + } + + ui.addNotification({ + title: _('load device error'), + text: _('Failed to load devices:'), + type: 'error', + delay: 5000 + }); + } }); - }, + } + + loadDevicesWithRetry(); +}, + // 加载现有的日志文件内容 loadExistingLog: function() { diff --git a/openwrt-packages/luci-app-partexp/luci-app-partexp/root/usr/share/rpcd/acl.d/luci-app-partexp.json b/openwrt-packages/luci-app-partexp/luci-app-partexp/root/usr/share/rpcd/acl.d/luci-app-partexp.json index 20bf3550c9..8f024a9978 100644 --- a/openwrt-packages/luci-app-partexp/luci-app-partexp/root/usr/share/rpcd/acl.d/luci-app-partexp.json +++ b/openwrt-packages/luci-app-partexp/luci-app-partexp/root/usr/share/rpcd/acl.d/luci-app-partexp.json @@ -1,19 +1,21 @@ { "luci-app-partexp": { - "description": "Grant UCI access for luci-app-partexp", - "read": { + "description": "Grant access for luci-app-partexp", + "read": { "ubus": { - "file": ["exec", "list", "stat", "read"], - "uci": [ "*" ], - "partexp": ["*"] + "file": ["exec", "list", "stat", "read"], + "uci": ["partexp"], + "partexp": ["get_devices", "get_log", "get_status"] } - }, - "write": { + }, + "write": { "ubus": { - "partexp": ["*"], - "file": ["write"], - "uci": ["*"] - } + "partexp": ["autopart", "save_config"], + "file": ["write"] + }, + "file": { + "/etc/config/partexp": ["write"] + } + } } - } } \ No newline at end of file diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua index 6e730639e2..927c757755 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua @@ -153,7 +153,8 @@ if load_balancing_options then -- [[ Load balancing Start ]] local descrStr = "Example: ^A && B && !C && D$
" descrStr = descrStr .. "This means the node remark must start with A (^), include B, exclude C (!), and end with D ($).
" descrStr = descrStr .. "Conditions are joined by &&, and their order does not affect the result." - o.description = translate(descrStr) + o.description = translate(descrStr) .. string.format("
%s", + translate("Keep the match scope small. Too many nodes can impact router performance.")) o = s:option(ListValue, _n("balancingStrategy"), translate("Balancing Strategy")) o:depends({ [_n("protocol")] = "_balancing" }) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua index f5397118e1..ebed4dcc84 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua @@ -152,7 +152,8 @@ if load_urltest_options then -- [[ URLTest Start ]] local descrStr = "Example: ^A && B && !C && D$
" descrStr = descrStr .. "This means the node remark must start with A (^), include B, exclude C (!), and end with D ($).
" descrStr = descrStr .. "Conditions are joined by &&, and their order does not affect the result." - o.description = translate(descrStr) + o.description = translate(descrStr) .. string.format("
%s", + translate("Keep the match scope small. Too many nodes can impact router performance.")) o = s:option(Value, _n("urltest_url"), translate("Probe URL")) o:depends({ [_n("protocol")] = "_urltest" }) diff --git a/openwrt-passwall/luci-app-passwall/luasrc/passwall/com.lua b/openwrt-passwall/luci-app-passwall/luasrc/passwall/com.lua index f990818386..9d37800764 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/passwall/com.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/passwall/com.lua @@ -45,8 +45,17 @@ _M["sing-box"] = { default_path = "/usr/bin/sing-box", match_fmt_str = "linux%%-%s", file_tree = { - x86_64 = "amd64", - mips64el = "mips64le" + x86_64 = "amd64%-musl", + x86 = "386%-musl", + aarch64 = "arm64%-musl", + rockchip = "arm64%-musl", + mips = "mips%-softfloat", + mips64 = "mips64%-softfloat", + mipsel = "mipsle%-softfloat%-musl", + mips64el = "mips64le%-softfloat", + armv7 = "armv7%-musl", + armv8 = "arm64%-musl", + riscv64 = "riscv64%-musl" } } diff --git a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua index 88eb8f0cb5..f2b1141e22 100644 --- a/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua +++ b/openwrt-passwall/luci-app-passwall/luasrc/passwall/util_xray.lua @@ -222,11 +222,11 @@ function gen_outbound(flag, node, tag, proxy_table) } or nil, grpcSettings = (node.transport == "grpc") and { serviceName = node.grpc_serviceName, - multiMode = (node.grpc_mode == "multi") and true or nil, - idle_timeout = tonumber(node.grpc_idle_timeout) or nil, + multiMode = (node.grpc_mode == "multi") and true or false, + idle_timeout = node.grpc_idle_timeout and (tonumber(node.grpc_idle_timeout) < 10 and 10 or tonumber(node.grpc_idle_timeout)) or nil, health_check_timeout = tonumber(node.grpc_health_check_timeout) or nil, - permit_without_stream = (node.grpc_permit_without_stream == "1") and true or nil, - initial_windows_size = tonumber(node.grpc_initial_windows_size) or nil, + permit_without_stream = (node.grpc_permit_without_stream == "1") and true or false, + initial_windows_size = node.grpc_initial_windows_size and tonumber(node.grpc_initial_windows_size) or 0, user_agent = node.user_agent } or nil, httpupgradeSettings = (node.transport == "httpupgrade") and { @@ -610,7 +610,7 @@ function gen_config_server(node) host = node.ws_host or nil, path = node.ws_path } or nil, - grpcSettings = (node.transport == "grpc") and { + grpcSettings = (node.transport == "grpc" and node.grpc_serviceName) and { serviceName = node.grpc_serviceName } or nil, httpupgradeSettings = (node.transport == "httpupgrade") and { diff --git a/openwrt-passwall/luci-app-passwall/po/zh-cn/passwall.po b/openwrt-passwall/luci-app-passwall/po/zh-cn/passwall.po index 53db76caac..e6b14faf4d 100644 --- a/openwrt-passwall/luci-app-passwall/po/zh-cn/passwall.po +++ b/openwrt-passwall/luci-app-passwall/po/zh-cn/passwall.po @@ -496,6 +496,9 @@ msgstr "" "表示节点备注需同时满足:以 A 开头(^)、包含 B、不包含 C(!)、并以 D 结尾($)。
" "多个条件使用 && 连接,条件顺序不影响结果。" +msgid "Keep the match scope small. Too many nodes can impact router performance." +msgstr "建议尽量缩小匹配范围,节点过多会增加路由器负载。" + msgid "Balancing Strategy" msgstr "负载均衡策略" diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/com.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/com.lua index b7ac89059a..aefc545958 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/com.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/com.lua @@ -35,8 +35,17 @@ _M["sing-box"] = { default_path = "/usr/bin/sing-box", match_fmt_str = "linux%%-%s", file_tree = { - x86_64 = "amd64", - mips64el = "mips64le" + x86_64 = "amd64%-musl", + x86 = "386%-musl", + aarch64 = "arm64%-musl", + rockchip = "arm64%-musl", + mips = "mips%-softfloat", + mips64 = "mips64%-softfloat", + mipsel = "mipsle%-softfloat%-musl", + mips64el = "mips64le%-softfloat", + armv7 = "armv7%-musl", + armv8 = "arm64%-musl", + riscv64 = "riscv64%-musl" } } diff --git a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_xray.lua b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_xray.lua index f4d33a260d..2388a3f048 100644 --- a/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_xray.lua +++ b/openwrt-passwall2/luci-app-passwall2/luasrc/passwall2/util_xray.lua @@ -218,11 +218,11 @@ function gen_outbound(flag, node, tag, proxy_table) } or nil, grpcSettings = (node.transport == "grpc") and { serviceName = node.grpc_serviceName, - multiMode = (node.grpc_mode == "multi") and true or nil, - idle_timeout = tonumber(node.grpc_idle_timeout) or nil, + multiMode = (node.grpc_mode == "multi") and true or false, + idle_timeout = node.grpc_idle_timeout and (tonumber(node.grpc_idle_timeout) < 10 and 10 or tonumber(node.grpc_idle_timeout)) or nil, health_check_timeout = tonumber(node.grpc_health_check_timeout) or nil, - permit_without_stream = (node.grpc_permit_without_stream == "1") and true or nil, - initial_windows_size = tonumber(node.grpc_initial_windows_size) or nil, + permit_without_stream = (node.grpc_permit_without_stream == "1") and true or false, + initial_windows_size = node.grpc_initial_windows_size and tonumber(node.grpc_initial_windows_size) or 0, user_agent = node.user_agent } or nil, httpupgradeSettings = (node.transport == "httpupgrade") and { @@ -605,7 +605,7 @@ function gen_config_server(node) host = node.ws_host or nil, path = node.ws_path } or nil, - grpcSettings = (node.transport == "grpc") and { + grpcSettings = (node.transport == "grpc" and node.grpc_serviceName) and { serviceName = node.grpc_serviceName } or nil, httpupgradeSettings = (node.transport == "httpupgrade") and { diff --git a/shadowsocks-rust/.github/workflows/build-nightly-release.yml b/shadowsocks-rust/.github/workflows/build-nightly-release.yml index 6e7d2f8cdb..f1ab1e2fb4 100644 --- a/shadowsocks-rust/.github/workflows/build-nightly-release.yml +++ b/shadowsocks-rust/.github/workflows/build-nightly-release.yml @@ -97,7 +97,7 @@ jobs: ./build-release -t ${{ matrix.platform.target }} $compile_features $compile_compress $compile_nightly $compile_cargo_flags - name: Upload Artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.platform.target }} path: build/release/* @@ -138,7 +138,7 @@ jobs: ./build/build-host-release -t ${{ matrix.target }} - name: Upload Artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.target }} path: build/release/* @@ -164,7 +164,7 @@ jobs: pwsh ./build/build-host-release.ps1 "full winservice" - name: Upload Artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: windows-native path: build/release/* diff --git a/shadowsocks-rust/Cargo.lock b/shadowsocks-rust/Cargo.lock index 8f7aa7662c..1d2f462f45 100644 --- a/shadowsocks-rust/Cargo.lock +++ b/shadowsocks-rust/Cargo.lock @@ -1923,9 +1923,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libloading" @@ -2208,9 +2208,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ "bitflags 2.10.0", "cfg-if", @@ -2419,18 +2419,18 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" dependencies = [ "proc-macro2", "quote", @@ -3332,7 +3332,7 @@ dependencies = [ "lru_time_cache", "mime", "native-tls", - "nix 0.31.1", + "nix 0.31.2", "pin-project", "rand 0.10.0", "regex", diff --git a/sing-box/clients/android/.editorconfig b/sing-box/clients/android/.editorconfig new file mode 100644 index 0000000000..a7e5c08079 --- /dev/null +++ b/sing-box/clients/android/.editorconfig @@ -0,0 +1,37 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 +indent_style = space +max_line_length = 140 +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_function-naming = disabled +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_property-naming = disabled + +[*.xml] +indent_size = 2 +indent_style = space + +[*.gradle] +indent_size = 4 +indent_style = space + +[*.gradle.kts] +indent_size = 4 +indent_style = space + +[*.json] +indent_size = 2 +indent_style = space + +[*.md] +trim_trailing_whitespace = false diff --git a/sing-box/clients/android/.gitignore b/sing-box/clients/android/.gitignore index 30679fb463..94ba292810 100644 --- a/sing-box/clients/android/.gitignore +++ b/sing-box/clients/android/.gitignore @@ -3,7 +3,7 @@ /local.properties /.idea/ .DS_Store -/build +build/ /captures .externalNativeBuild .cxx diff --git a/sing-box/clients/android/app/build.gradle b/sing-box/clients/android/app/build.gradle deleted file mode 100644 index 228d38ff44..0000000000 --- a/sing-box/clients/android/app/build.gradle +++ /dev/null @@ -1,202 +0,0 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - -plugins { - id "com.android.application" - id "kotlin-android" - id "kotlin-parcelize" - id "com.google.devtools.ksp" - id "org.jetbrains.kotlin.plugin.compose" - id "com.github.triplet.play" -} - -android { - namespace "io.nekohasekai.sfa" - compileSdk 36 - - ndkVersion "28.0.13004108" - - def ndkPathFromEnv = System.getenv("ANDROID_NDK_HOME") - if (ndkPathFromEnv != null) { - ndkPath ndkPathFromEnv - } - - ksp { - arg("room.incremental", "true") - arg("room.schemaLocation", "$projectDir/schemas") - } - - defaultConfig { - applicationId "io.nekohasekai.sfa" - minSdk 21 - targetSdk 35 - versionCode getVersionProps("VERSION_CODE").toInteger() - versionName getVersionProps("VERSION_NAME") - setProperty("archivesBaseName", "SFA-" + versionName) - } - - signingConfigs { - release { - storeFile file("release.keystore") - storePassword getProps("KEYSTORE_PASS") - keyAlias getProps("ALIAS_NAME") - keyPassword getProps("ALIAS_PASS") - } - } - - buildTypes { - debug { - if (getProps("KEYSTORE_PASS") != "") { - signingConfig signingConfigs.release - } - } - release { - minifyEnabled true - proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" - signingConfig signingConfigs.release - vcsInfo.include false - } - } - - dependenciesInfo { - includeInApk = false - } - - flavorDimensions "vendor" - productFlavors { - play { - } - other { - } - } - - splits { - abi { - enable true - universalApk true - reset() - include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - buildFeatures { - viewBinding true - aidl true - compose true - } - - applicationVariants.configureEach { variant -> - variant.outputs.configureEach { - outputFileName = (outputFileName as String).replace("-release", "") - outputFileName = (outputFileName as String).replace("-play", "") - outputFileName = (outputFileName as String).replace("-other", "-foss") - } - } -} - -dependencies { - implementation(fileTree("libs")) - - implementation "androidx.core:core-ktx:1.16.0" - implementation "androidx.appcompat:appcompat:1.7.1" - implementation "com.google.android.material:material:1.12.0" - implementation "androidx.constraintlayout:constraintlayout:2.2.1" - implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.9.2" - implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.2" - implementation "androidx.navigation:navigation-fragment-ktx:2.9.3" - implementation "androidx.navigation:navigation-ui-ktx:2.9.3" - implementation "com.google.zxing:core:3.5.3" - implementation "androidx.room:room-runtime:2.7.2" - implementation "androidx.coordinatorlayout:coordinatorlayout:1.3.0" - implementation "androidx.preference:preference-ktx:1.2.1" - implementation "androidx.camera:camera-view:1.4.2" - implementation "androidx.camera:camera-lifecycle:1.4.2" - implementation "androidx.camera:camera-camera2:1.4.2" - ksp "androidx.room:room-compiler:2.7.2" - implementation "androidx.work:work-runtime-ktx:2.10.3" - implementation "androidx.browser:browser:1.9.0" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2" - - // DO NOT UPDATE (minSdkVersion updated) - implementation "com.blacksquircle.ui:editorkit:2.2.0" - implementation "com.blacksquircle.ui:language-json:2.2.0" - - implementation("com.android.tools.smali:smali-dexlib2:3.0.9") { - exclude group: "com.google.guava", module: "guava" - } - implementation "com.google.guava:guava:33.4.8-android" - playImplementation "com.google.android.play:app-update-ktx:2.1.0" - playImplementation "com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1" - - def composeBom = platform('androidx.compose:compose-bom:2025.07.00') - implementation composeBom - androidTestImplementation composeBom - implementation 'androidx.compose.material3:material3' - implementation 'androidx.compose.ui:ui-tooling-preview' - debugImplementation 'androidx.compose.ui:ui-tooling' - androidTestImplementation 'androidx.compose.ui:ui-test-junit4' - debugImplementation 'androidx.compose.ui:ui-test-manifest' - implementation 'androidx.compose.material:material-icons-extended' - implementation 'androidx.activity:activity-compose:1.10.1' - implementation 'me.zhanghai.compose.preference:library:1.1.1' - implementation "androidx.navigation:navigation-compose:2.9.3" -} - -def playCredentialsJSON = rootProject.file("service-account-credentials.json") -if (playCredentialsJSON.exists()) { - play { - serviceAccountCredentials = playCredentialsJSON - defaultToAppBundles = true - def version = getVersionProps("VERSION_NAME") - if (version.contains("alpha") || version.contains("beta") || version.contains("rc")) { - track = "beta" - } else { - track = "production" - } - } -} - -tasks.withType(KotlinCompile.class).configureEach { - kotlinOptions { - jvmTarget = "1.8" - } -} - -def getProps(String propName) { - def propsInEnv = System.getenv("LOCAL_PROPERTIES") - if (propsInEnv != null) { - def props = new Properties() - props.load(new ByteArrayInputStream(Base64.decoder.decode(propsInEnv))) - String value = props[propName] - if (value != null) { - return value - } - } - def propsFile = rootProject.file("local.properties") - if (propsFile.exists()) { - def props = new Properties() - props.load(new FileInputStream(propsFile)) - String value = props[propName] - if (value != null) { - return value - } - } - return "" -} - -def getVersionProps(String propName) { - def propsFile = rootProject.file("version.properties") - if (propsFile.exists()) { - def props = new Properties() - props.load(new FileInputStream(propsFile)) - String value = props[propName] - if (value != null) { - return value - } - } - return "" -} \ No newline at end of file diff --git a/sing-box/clients/android/app/build.gradle.kts b/sing-box/clients/android/app/build.gradle.kts new file mode 100644 index 0000000000..a7394e5f04 --- /dev/null +++ b/sing-box/clients/android/app/build.gradle.kts @@ -0,0 +1,366 @@ +import org.gradle.api.file.DuplicatesStrategy +import org.gradle.api.tasks.Sync +import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import java.io.ByteArrayInputStream +import java.io.FileInputStream +import java.util.Base64 +import java.util.Properties + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.parcelize") + id("com.google.devtools.ksp") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") + id("com.github.triplet.play") + alias(libs.plugins.spotless) +} + +fun getProps(propName: String): String { + val propsInEnv = System.getenv("LOCAL_PROPERTIES") + if (propsInEnv != null) { + val props = Properties() + props.load(ByteArrayInputStream(Base64.getDecoder().decode(propsInEnv))) + val value = props.getProperty(propName) + if (value != null) { + return value + } + } + val propsFile = rootProject.file("local.properties") + if (propsFile.exists()) { + val props = Properties() + props.load(FileInputStream(propsFile)) + val value = props.getProperty(propName) + if (value != null) { + return value + } + } + return "" +} + +fun getVersionProps(propName: String): String { + val propsFile = rootProject.file("version.properties") + if (propsFile.exists()) { + val props = Properties() + props.load(FileInputStream(propsFile)) + val value = props.getProperty(propName) + if (value != null) { + return value + } + } + return "" +} + +android { + namespace = "io.nekohasekai.sfa" + compileSdk = 36 + + ndkVersion = "28.0.13004108" + + System.getenv("ANDROID_NDK_HOME")?.let { ndkPath = it } + + ksp { + arg("room.incremental", "true") + arg("room.schemaLocation", "${projectDir}/schemas") + } + + defaultConfig { + applicationId = "io.nekohasekai.sfa" + minSdk = 21 + targetSdk = 35 + versionCode = getVersionProps("VERSION_CODE").toInt() + versionName = getVersionProps("VERSION_NAME") + base.archivesName.set("SFA-${versionName}") + } + + signingConfigs { + create("release") { + storeFile = file("release.keystore") + storePassword = getProps("KEYSTORE_PASS") + keyAlias = getProps("ALIAS_NAME") + keyPassword = getProps("ALIAS_PASS") + } + } + + buildTypes { + debug { + if (getProps("KEYSTORE_PASS").isNotEmpty()) { + signingConfig = signingConfigs.getByName("release") + } + } + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("release") + vcsInfo.include = false + } + } + + dependenciesInfo { + includeInApk = false + } + + flavorDimensions += "vendor" + productFlavors { + create("play") { + minSdk = 23 + } + create("other") { + minSdk = 23 + } + create("otherLegacy") { + minSdk = 21 + } + } + + sourceSets { + getByName("play") { + java.directories.add("src/minApi23/java") + aidl.directories.add("src/minApi23/aidl") + } + getByName("other") { + java.directories.addAll(listOf("src/minApi23/java", "src/github/java")) + aidl.directories.add("src/minApi23/aidl") + } + getByName("otherLegacy") { + java.directories.addAll(listOf("src/minApi21/java", "src/github/java")) + aidl.directories.add("src/minApi23/aidl") + } + } + + splits { + abi { + isEnable = true + isUniversalApk = true + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + androidResources { + generateLocaleConfig = true + } + + buildFeatures { + viewBinding = true + aidl = true + compose = true + buildConfig = true + } + + packaging { + jniLibs { + useLegacyPackaging = true + } + } + + applicationVariants.configureEach { + outputs.configureEach { + val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl + var fileName = output.outputFileName + fileName = fileName.replace("-release", "") + fileName = fileName.replace("-play", "-play") + fileName = fileName.replace("-otherLegacy", "-legacy-android-5") + fileName = fileName.replace("-other", "") + output.outputFileName = fileName + } + } +} + +dependencies { + // libbox + "playImplementation"(files("libs/libbox.aar")) + "otherImplementation"(files("libs/libbox.aar")) + "otherLegacyImplementation"(files("libs/libbox-legacy.aar")) + + // API level specific versions + val lifecycleVersion23 = "2.10.0" + val roomVersion23 = "2.8.4" + val workVersion23 = "2.11.1" + val cameraVersion23 = "1.5.3" + val browserVersion23 = "1.9.0" + + val lifecycleVersion21 = "2.9.4" + val roomVersion21 = "2.7.2" + val workVersion21 = "2.10.5" + val cameraVersion21 = "1.4.2" + val browserVersion21 = "1.9.0" + + // Common dependencies (no API level difference) + implementation("androidx.core:core-ktx:1.17.0") + implementation("androidx.appcompat:appcompat:1.7.1") + implementation("com.google.android.material:material:1.13.0") + implementation("androidx.constraintlayout:constraintlayout:2.2.1") + implementation("androidx.navigation:navigation-fragment-ktx:2.9.7") + implementation("androidx.navigation:navigation-ui-ktx:2.9.7") + implementation("com.google.zxing:core:3.5.4") + implementation("androidx.coordinatorlayout:coordinatorlayout:1.3.0") + implementation("androidx.preference:preference-ktx:1.2.1") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") + implementation("com.blacksquircle.ui:editorkit:2.2.0") + implementation("com.blacksquircle.ui:language-json:2.2.0") + implementation("com.android.tools.smali:smali-dexlib2:3.0.9") { + exclude(group = "com.google.guava", module = "guava") + } + implementation("com.google.guava:guava:33.5.0-android") + + // API 23+ dependencies (play/other) + "playImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion23") + "playImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion23") + "playImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion23") + "playImplementation"("androidx.room:room-runtime:$roomVersion23") + "playImplementation"("androidx.work:work-runtime-ktx:$workVersion23") + "playImplementation"("androidx.camera:camera-view:$cameraVersion23") + "playImplementation"("androidx.camera:camera-lifecycle:$cameraVersion23") + "playImplementation"("androidx.camera:camera-camera2:$cameraVersion23") + "playImplementation"("androidx.browser:browser:$browserVersion23") + "playAnnotationProcessor"("androidx.room:room-compiler:$roomVersion23") + "kspPlay"("androidx.room:room-compiler:$roomVersion23") + + "otherImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion23") + "otherImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion23") + "otherImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion23") + "otherImplementation"("androidx.room:room-runtime:$roomVersion23") + "otherImplementation"("androidx.work:work-runtime-ktx:$workVersion23") + "otherImplementation"("androidx.camera:camera-view:$cameraVersion23") + "otherImplementation"("androidx.camera:camera-lifecycle:$cameraVersion23") + "otherImplementation"("androidx.camera:camera-camera2:$cameraVersion23") + "otherImplementation"("androidx.browser:browser:$browserVersion23") + "kspOther"("androidx.room:room-compiler:$roomVersion23") + + // API 21 dependencies (otherLegacy) + "otherLegacyImplementation"("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion21") + "otherLegacyImplementation"("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion21") + "otherLegacyImplementation"("androidx.lifecycle:lifecycle-process:$lifecycleVersion21") + "otherLegacyImplementation"("androidx.room:room-runtime:$roomVersion21") + "otherLegacyImplementation"("androidx.work:work-runtime-ktx:$workVersion21") + "otherLegacyImplementation"("androidx.camera:camera-view:$cameraVersion21") + "otherLegacyImplementation"("androidx.camera:camera-lifecycle:$cameraVersion21") + "otherLegacyImplementation"("androidx.camera:camera-camera2:$cameraVersion21") + "otherLegacyImplementation"("androidx.browser:browser:$browserVersion21") + "kspOtherLegacy"("androidx.room:room-compiler:$roomVersion21") + + // Play Store specific + "playImplementation"("com.google.android.play:app-update-ktx:2.1.0") + "playImplementation"("com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1") + + // Shizuku (play and other flavors, API 23+ only) + val shizukuVersion = "12.2.0" + "playImplementation"("dev.rikka.shizuku:api:$shizukuVersion") + "playImplementation"("dev.rikka.shizuku:provider:$shizukuVersion") + "otherImplementation"("dev.rikka.shizuku:api:$shizukuVersion") + "otherImplementation"("dev.rikka.shizuku:provider:$shizukuVersion") + + // libsu for ROOT package query (all flavors) + val libsuVersion = "6.0.0" + "playImplementation"("com.github.topjohnwu.libsu:core:$libsuVersion") + "playImplementation"("com.github.topjohnwu.libsu:service:$libsuVersion") + "otherImplementation"("com.github.topjohnwu.libsu:core:$libsuVersion") + "otherImplementation"("com.github.topjohnwu.libsu:service:$libsuVersion") + "otherLegacyImplementation"("com.github.topjohnwu.libsu:core:$libsuVersion") + "otherLegacyImplementation"("com.github.topjohnwu.libsu:service:$libsuVersion") + + // Compose dependencies - API 23+ (play/other) + val composeBom23 = platform("androidx.compose:compose-bom:2026.02.00") + val activityVersion23 = "1.12.4" + val lifecycleComposeVersion23 = "2.10.0" + + "playImplementation"(composeBom23) + "playImplementation"("androidx.compose.material3:material3") + "playImplementation"("androidx.compose.material3.adaptive:adaptive") + "playImplementation"("androidx.compose.ui:ui") + "playImplementation"("androidx.compose.ui:ui-tooling-preview") + "playImplementation"("androidx.compose.material:material-icons-extended") + "playImplementation"("androidx.activity:activity-compose:$activityVersion23") + "playImplementation"("androidx.navigation:navigation-compose:2.9.7") + "playImplementation"("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleComposeVersion23") + "playImplementation"("androidx.compose.runtime:runtime-livedata") + + "otherImplementation"(composeBom23) + "otherImplementation"("androidx.compose.material3:material3") + "otherImplementation"("androidx.compose.material3.adaptive:adaptive") + "otherImplementation"("androidx.compose.ui:ui") + "otherImplementation"("androidx.compose.ui:ui-tooling-preview") + "otherImplementation"("androidx.compose.material:material-icons-extended") + "otherImplementation"("androidx.activity:activity-compose:$activityVersion23") + "otherImplementation"("androidx.navigation:navigation-compose:2.9.7") + "otherImplementation"("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleComposeVersion23") + "otherImplementation"("androidx.compose.runtime:runtime-livedata") + + // Compose dependencies - API 21 (otherLegacy) + val composeBom21 = platform("androidx.compose:compose-bom:2025.01.00") + val activityVersion21 = "1.11.0" + val lifecycleComposeVersion21 = "2.9.4" + + "otherLegacyImplementation"(composeBom21) + "otherLegacyImplementation"("androidx.compose.material3:material3") + "otherLegacyImplementation"("androidx.compose.material3.adaptive:adaptive") + "otherLegacyImplementation"("androidx.compose.ui:ui") + "otherLegacyImplementation"("androidx.compose.ui:ui-tooling-preview") + "otherLegacyImplementation"("androidx.compose.material:material-icons-extended") + "otherLegacyImplementation"("androidx.activity:activity-compose:$activityVersion21") + "otherLegacyImplementation"("androidx.navigation:navigation-compose:2.9.7") + "otherLegacyImplementation"("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleComposeVersion21") + "otherLegacyImplementation"("androidx.compose.runtime:runtime-livedata") + + // Debug/Test dependencies + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + + // Common Compose-related libraries + implementation("sh.calvin.reorderable:reorderable:3.0.0") + implementation("com.github.jeziellago:compose-markdown:0.5.8") + implementation("org.kodein.emoji:emoji-kt:2.3.0") + + // Xposed API for self-hooking VPN hide module + compileOnly("de.robv.android.xposed:api:82") + compileOnly(project(":libxposed-api")) +} + +val playCredentialsJSON = rootProject.file("service-account-credentials.json") +if (playCredentialsJSON.exists()) { + play { + serviceAccountCredentials.set(playCredentialsJSON) + defaultToAppBundles.set(true) + val version = getVersionProps("VERSION_NAME") + track.set( + if (version.contains("alpha") || version.contains("beta")/* || version.contains("rc")*/) { + "beta" + } else { + "production" + } + ) + } +} + +tasks.withType().configureEach { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +spotless { + kotlin { + target("src/**/*.kt") + ktlint(libs.versions.ktlint.get()) + .editorConfigOverride(mapOf( + "ktlint_standard_backing-property-naming" to "disabled", + "ktlint_standard_filename" to "disabled", + "ktlint_standard_max-line-length" to "disabled", + "ktlint_standard_property-naming" to "disabled", + )) + } + java { + target("src/**/*.java") + googleJavaFormat() + } +} diff --git a/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json b/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json index ec8282d392..b7cac4cdd3 100644 --- a/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json +++ b/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "b7bfa362ec191b0a18660e615da81e46", + "identityHash": "24de05fe91b147c75b870f91b2f4871b", "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, `typed` BLOB NOT NULL)", + "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, `typed` BLOB NOT NULL)", "fields": [ { "fieldPath": "id", @@ -26,6 +26,11 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, { "fieldPath": "typed", "columnName": "typed", @@ -38,15 +43,12 @@ "columnNames": [ "id" ] - }, - "indices": [], - "foreignKeys": [] + } } ], - "views": [], "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, 'b7bfa362ec191b0a18660e615da81e46')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '24de05fe91b147c75b870f91b2f4871b')" ] } } \ No newline at end of file diff --git a/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json b/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json new file mode 100644 index 0000000000..bd414a07eb --- /dev/null +++ b/sing-box/clients/android/app/schemas/io.nekohasekai.sfa.database.ProfileDatabase/2.json @@ -0,0 +1,55 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "dc5fb65e389df8c8391b3435652f4c64", + "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" + ] + } + } + ], + "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, 'dc5fb65e389df8c8391b3435652f4c64')" + ] + } +} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/github/AndroidManifest.xml b/sing-box/clients/android/app/src/github/AndroidManifest.xml new file mode 100644 index 0000000000..85fdf1e052 --- /dev/null +++ b/sing-box/clients/android/app/src/github/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt new file mode 100644 index 0000000000..98ce882d59 --- /dev/null +++ b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/ApkDownloader.kt @@ -0,0 +1,43 @@ +package io.nekohasekai.sfa.vendor + +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.utils.HTTPClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.Closeable +import java.io.File + +class ApkDownloader : Closeable { + private val client = Libbox.newHTTPClient().apply { + modernTLS() + keepAlive() + } + + suspend fun download(url: String): File = withContext(Dispatchers.IO) { + val cacheDir = File(Application.application.cacheDir, "updates") + cacheDir.mkdirs() + val apkFile = File(cacheDir, "update.apk") + + if (apkFile.exists()) apkFile.delete() + + val request = client.newRequest() + request.setUserAgent(HTTPClient.userAgent) + request.setURL(url) + + val response = request.execute() + response.writeTo(apkFile.absolutePath) + + if (!apkFile.exists() || apkFile.length() == 0L) { + throw Exception("Download failed: empty file") + } + + UpdateState.saveApkPath(apkFile) + apkFile + } + + override fun close() { + client.close() + } +} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt new file mode 100644 index 0000000000..d241a2c37f --- /dev/null +++ b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/GitHubUpdateChecker.kt @@ -0,0 +1,149 @@ +package io.nekohasekai.sfa.vendor + +import android.os.Build +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.ktx.unwrap +import io.nekohasekai.sfa.update.UpdateInfo +import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.utils.HTTPClient +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.Closeable + +class GitHubUpdateChecker : Closeable { + companion object { + private const val RELEASES_URL = "https://api.github.com/repos/SagerNet/sing-box/releases" + private const val METADATA_FILENAME = "SFA-version-metadata.json" + } + + private val client = Libbox.newHTTPClient().apply { + modernTLS() + keepAlive() + } + + private val json = Json { ignoreUnknownKeys = true } + + fun checkUpdate(track: UpdateTrack): UpdateInfo? { + val releases = getReleases() + var selected: ReleaseCandidate? = null + + for (release in releases) { + if (!isReleaseInTrack(release, track)) { + continue + } + val metadata = runCatching { downloadMetadata(release) }.getOrNull() ?: continue + if (!isNewerThanCurrent(metadata.versionName)) { + continue + } + val currentBest = selected + if (currentBest == null || isBetterVersion(metadata, currentBest.metadata)) { + selected = ReleaseCandidate(release, metadata) + } + } + + val release = selected?.release ?: return null + val metadata = selected.metadata + + val isLegacy = Build.VERSION.SDK_INT < Build.VERSION_CODES.M + val apkAsset = release.assets.find { asset -> + asset.name.endsWith(".apk") && + !asset.name.contains("play") && + asset.name.contains("legacy-android-5") == isLegacy + } + + return UpdateInfo( + versionCode = metadata.versionCode, + versionName = metadata.versionName, + downloadUrl = apkAsset?.browserDownloadUrl ?: release.htmlUrl, + releaseUrl = release.htmlUrl, + releaseNotes = release.body, + isPrerelease = release.prerelease, + fileSize = apkAsset?.size ?: 0, + ) + } + + private fun getReleases(): List { + val request = client.newRequest() + request.setURL(RELEASES_URL) + request.setHeader("Accept", "application/vnd.github.v3+json") + request.setUserAgent(HTTPClient.userAgent) + + val response = request.execute() + val content = response.content.unwrap + + return json.decodeFromString(content) + } + + private fun isReleaseInTrack(release: GitHubRelease, track: UpdateTrack): Boolean { + if (release.draft) { + return false + } + return when (track) { + UpdateTrack.STABLE -> !release.prerelease + UpdateTrack.BETA -> true + } + } + + private fun isNewerThanCurrent(versionName: String): Boolean { + return Libbox.compareSemver(versionName, BuildConfig.VERSION_NAME) + } + + private fun isBetterVersion(version: VersionMetadata, other: VersionMetadata): Boolean { + if (Libbox.compareSemver(version.versionName, other.versionName)) { + return true + } + if (Libbox.compareSemver(other.versionName, version.versionName)) { + return false + } + return version.versionCode > other.versionCode + } + + private fun downloadMetadata(release: GitHubRelease): VersionMetadata? { + val metadataAsset = release.assets.find { it.name == METADATA_FILENAME } + ?: return null + + val request = client.newRequest() + request.setURL(metadataAsset.browserDownloadUrl) + request.setUserAgent(HTTPClient.userAgent) + + val response = request.execute() + val content = response.content.unwrap + + return json.decodeFromString(content) + } + + override fun close() { + client.close() + } + + @Serializable + data class GitHubRelease( + @SerialName("tag_name") val tagName: String = "", + val name: String = "", + val body: String? = null, + val draft: Boolean = false, + val prerelease: Boolean = false, + @SerialName("html_url") val htmlUrl: String = "", + val assets: List = emptyList(), + ) + + @Serializable + data class GitHubAsset( + val name: String = "", + @SerialName("browser_download_url") val browserDownloadUrl: String = "", + val size: Long = 0, + ) + + @Serializable + data class VersionMetadata( + @SerialName("version_code") val versionCode: Int = 0, + @SerialName("version_name") val versionName: String = "", + ) + + private data class ReleaseCandidate( + val release: GitHubRelease, + val metadata: VersionMetadata, + ) +} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt new file mode 100644 index 0000000000..b3a082f796 --- /dev/null +++ b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/InstallResultReceiver.kt @@ -0,0 +1,47 @@ +package io.nekohasekai.sfa.vendor + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.util.Log +import io.nekohasekai.sfa.update.UpdateState + +class InstallResultReceiver : BroadcastReceiver() { + companion object { + const val ACTION_INSTALL_COMPLETE = "io.nekohasekai.sfa.INSTALL_COMPLETE" + private const val TAG = "InstallResultReceiver" + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION_INSTALL_COMPLETE) return + + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + + Log.d(TAG, "Install result: status=$status, message=$message") + + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val confirmIntent = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } + confirmIntent?.let { + it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(it) + } + } + PackageInstaller.STATUS_SUCCESS -> { + Log.d(TAG, "Installation successful") + UpdateState.setInstallStatus(UpdateState.InstallStatus.Success) + } + else -> { + Log.e(TAG, "Installation failed: $status - $message") + UpdateState.setInstallStatus(UpdateState.InstallStatus.Failed(message ?: "Unknown error")) + } + } + } +} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt new file mode 100644 index 0000000000..482ca04086 --- /dev/null +++ b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/RootInstaller.kt @@ -0,0 +1,79 @@ +package io.nekohasekai.sfa.vendor + +import android.content.Intent +import android.content.ServiceConnection +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.ParcelFileDescriptor +import com.topjohnwu.superuser.ipc.RootService +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.bg.IRootService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +object RootInstaller { + + suspend fun install(apkFile: File) { + withContext(Dispatchers.IO) { + bindRootService().use { handle -> + ParcelFileDescriptor.open(apkFile, ParcelFileDescriptor.MODE_READ_ONLY).use { pfd -> + handle.service.installPackage( + pfd, + apkFile.length(), + android.os.Process.myUserHandle().hashCode(), + ) + } + } + } + } + + private suspend fun bindRootService(): RootServiceHandle { + return withContext(Dispatchers.Main) { + suspendCancellableCoroutine { continuation -> + val conn = object : ServiceConnection { + override fun onServiceConnected(name: android.content.ComponentName?, binder: IBinder?) { + val svc = if (binder != null && binder.pingBinder()) { + IRootService.Stub.asInterface(binder) + } else { + null + } + if (svc == null) { + continuation.resumeWithException(IllegalStateException("Invalid root service binder")) + return + } + continuation.resume(RootServiceHandle(this, svc)) + } + + override fun onServiceDisconnected(name: android.content.ComponentName?) { + // Ignored + } + } + + try { + val intent = Intent(Application.application, Class.forName("io.nekohasekai.sfa.bg.RootServer")) + RootService.bind(intent, conn) + } catch (e: Throwable) { + continuation.resumeWithException(e) + return@suspendCancellableCoroutine + } + + continuation.invokeOnCancellation { + RootService.unbind(conn) + } + } + } + } + + private class RootServiceHandle(val connection: ServiceConnection, val service: IRootService) : java.io.Closeable { + override fun close() { + Handler(Looper.getMainLooper()).post { + RootService.unbind(connection) + } + } + } +} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt new file mode 100644 index 0000000000..149c4f74d0 --- /dev/null +++ b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/SystemPackageInstaller.kt @@ -0,0 +1,45 @@ +package io.nekohasekai.sfa.vendor + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import java.io.File +import java.io.FileInputStream +import android.content.pm.PackageInstaller as AndroidPackageInstaller + +object SystemPackageInstaller { + + fun canSystemSilentInstall(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + + fun install(context: Context, apkFile: File) { + val packageInstaller = context.packageManager.packageInstaller + val params = AndroidPackageInstaller.SessionParams(AndroidPackageInstaller.SessionParams.MODE_FULL_INSTALL) + params.setAppPackageName(context.packageName) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + params.setRequireUserAction(AndroidPackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + } + + val sessionId = packageInstaller.createSession(params) + packageInstaller.openSession(sessionId).use { session -> + session.openWrite("update.apk", 0, apkFile.length()).use { outputStream -> + FileInputStream(apkFile).use { inputStream -> + inputStream.copyTo(outputStream) + } + session.fsync(outputStream) + } + + val intent = Intent(context, InstallResultReceiver::class.java).apply { + action = InstallResultReceiver.ACTION_INSTALL_COMPLETE + } + val pendingIntent = PendingIntent.getBroadcast( + context, + sessionId, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + + session.commit(pendingIntent.intentSender) + } + } +} diff --git a/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt new file mode 100644 index 0000000000..7b1457312b --- /dev/null +++ b/sing-box/clients/android/app/src/github/java/io/nekohasekai/sfa/vendor/UpdateWorker.kt @@ -0,0 +1,90 @@ +package io.nekohasekai.sfa.vendor + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.update.UpdateTrack +import java.util.concurrent.TimeUnit + +class UpdateWorker(private val appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + + companion object { + private const val WORK_NAME = "AutoUpdate" + private const val TAG = "UpdateWorker" + + fun schedule(context: Context) { + if (!Settings.autoUpdateEnabled) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME) + Log.d(TAG, "Auto update disabled, cancelled scheduled work") + return + } + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + + val workRequest = PeriodicWorkRequestBuilder( + 24, + TimeUnit.HOURS, + ) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.HOURS) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + workRequest, + ) + Log.d(TAG, "Auto update scheduled") + } + } + + override suspend fun doWork(): Result { + if (!Settings.autoUpdateEnabled) { + Log.d(TAG, "Auto update disabled, skipping") + return Result.success() + } + + Log.d(TAG, "Checking for updates...") + + return try { + val track = UpdateTrack.fromString(Settings.updateTrack) + val updateInfo = GitHubUpdateChecker().use { it.checkUpdate(track) } + + if (updateInfo == null) { + Log.d(TAG, "No update available") + return Result.success() + } + + Log.d(TAG, "Update available: ${updateInfo.versionName}") + UpdateState.setUpdate(updateInfo) + + if (Settings.silentInstallEnabled && ApkInstaller.canSilentInstall()) { + Log.d(TAG, "Downloading update...") + val apkFile = ApkDownloader().use { it.download(updateInfo.downloadUrl) } + + Log.d(TAG, "Installing update...") + ApkInstaller.install(appContext, apkFile) + Log.d(TAG, "Update installed successfully") + } else { + Log.d(TAG, "Silent install not available, update will be shown on next app launch") + } + + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Auto update failed", e) + Result.retry() + } + } +} diff --git a/sing-box/clients/android/app/src/main/AndroidManifest.xml b/sing-box/clients/android/app/src/main/AndroidManifest.xml index 04b6a734f6..5d9d8ffc72 100644 --- a/sing-box/clients/android/app/src/main/AndroidManifest.xml +++ b/sing-box/clients/android/app/src/main/AndroidManifest.xml @@ -21,6 +21,10 @@ + + + @@ -29,6 +33,7 @@ android:name=".Application" android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" + android:description="@string/xposed_description" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" @@ -36,30 +41,23 @@ android:theme="@style/AppTheme" tools:targetApi="31"> - - - - - - - + android:launchMode="singleTask" + android:theme="@style/AppTheme"> + + + + + @@ -93,50 +91,24 @@ + + + + + + + + + + + + + - - - - - - - - - - - - - - - - + + + + + + + + - \ No newline at end of file + diff --git a/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedScopeCallback.aidl b/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedScopeCallback.aidl new file mode 100644 index 0000000000..6dcf980a12 --- /dev/null +++ b/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedScopeCallback.aidl @@ -0,0 +1,9 @@ +package io.github.libxposed.service; + +interface IXposedScopeCallback { + oneway void onScopeRequestPrompted(String packageName) = 1; + oneway void onScopeRequestApproved(String packageName) = 2; + oneway void onScopeRequestDenied(String packageName) = 3; + oneway void onScopeRequestTimeout(String packageName) = 4; + oneway void onScopeRequestFailed(String packageName, String message) = 5; +} diff --git a/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedService.aidl b/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedService.aidl new file mode 100644 index 0000000000..58fcae80df --- /dev/null +++ b/sing-box/clients/android/app/src/main/aidl/io/github/libxposed/service/IXposedService.aidl @@ -0,0 +1,36 @@ +package io.github.libxposed.service; +import io.github.libxposed.service.IXposedScopeCallback; + +interface IXposedService { + const int API = 100; + + const int FRAMEWORK_PRIVILEGE_ROOT = 0; + const int FRAMEWORK_PRIVILEGE_CONTAINER = 1; + const int FRAMEWORK_PRIVILEGE_APP = 2; + const int FRAMEWORK_PRIVILEGE_EMBEDDED = 3; + + const String AUTHORITY_SUFFIX = ".XposedService"; + const String SEND_BINDER = "SendBinder"; + + // framework details + int getAPIVersion() = 1; + String getFrameworkName() = 2; + String getFrameworkVersion() = 3; + long getFrameworkVersionCode() = 4; + int getFrameworkPrivilege() = 5; + + // scope utilities + List getScope() = 10; + oneway void requestScope(String packageName, IXposedScopeCallback callback) = 11; + String removeScope(String packageName) = 12; + + // remote preference utilities + Bundle requestRemotePreferences(String group) = 20; + void updateRemotePreferences(String group, in Bundle diff) = 21; + void deleteRemotePreferences(String group) = 22; + + // remote file utilities + String[] listRemoteFiles() = 30; + ParcelFileDescriptor openRemoteFile(String name) = 31; + boolean deleteRemoteFile(String name) = 32; +} diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl new file mode 100644 index 0000000000..fc58161157 --- /dev/null +++ b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IRootService.aidl @@ -0,0 +1,14 @@ +package io.nekohasekai.sfa.bg; + +import android.os.ParcelFileDescriptor; +import io.nekohasekai.sfa.bg.ParceledListSlice; + +interface IRootService { + void destroy() = 16777114; // Destroy method defined by Shizuku server + + ParceledListSlice getInstalledPackages(int flags, int userId) = 1; + + void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2; + + String exportDebugInfo(String outputPath) = 3; +} diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl new file mode 100644 index 0000000000..8241f560dc --- /dev/null +++ b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/IShizukuService.aidl @@ -0,0 +1,12 @@ +package io.nekohasekai.sfa.bg; + +import android.os.ParcelFileDescriptor; +import io.nekohasekai.sfa.bg.ParceledListSlice; + +interface IShizukuService { + void destroy() = 16777114; // Destroy method defined by Shizuku server + + ParceledListSlice getInstalledPackages(int flags, int userId) = 1; + + void installPackage(in ParcelFileDescriptor apk, long size, int userId) = 2; +} diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl new file mode 100644 index 0000000000..62ca37609b --- /dev/null +++ b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/LogEntry.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable LogEntry; diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl new file mode 100644 index 0000000000..db569f7049 --- /dev/null +++ b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/PackageEntry.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable PackageEntry; diff --git a/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl new file mode 100644 index 0000000000..4eaec8b854 --- /dev/null +++ b/sing-box/clients/android/app/src/main/aidl/io/nekohasekai/sfa/bg/ParceledListSlice.aidl @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.bg; + +parcelable ParceledListSlice; diff --git a/sing-box/clients/android/app/src/main/java/android/content/IIntentReceiver.java b/sing-box/clients/android/app/src/main/java/android/content/IIntentReceiver.java new file mode 100644 index 0000000000..b0461681b4 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/android/content/IIntentReceiver.java @@ -0,0 +1,15 @@ +package android.content; + +import android.os.Bundle; +import android.os.IInterface; + +public interface IIntentReceiver extends IInterface { + void performReceive( + Intent intent, + int resultCode, + String data, + Bundle extras, + boolean ordered, + boolean sticky, + int sendingUser); +} diff --git a/sing-box/clients/android/app/src/main/java/android/content/IIntentSender.java b/sing-box/clients/android/app/src/main/java/android/content/IIntentSender.java new file mode 100644 index 0000000000..51d705733b --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/android/content/IIntentSender.java @@ -0,0 +1,29 @@ +package android.content; + +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.os.IInterface; + +public interface IIntentSender extends IInterface { + + void send( + int code, + Intent intent, + String resolvedType, + IBinder whitelistToken, + IIntentReceiver finishedReceiver, + String requiredPermission, + Bundle options); + + abstract class Stub extends Binder implements IIntentSender { + public static IIntentSender asInterface(IBinder binder) { + throw new UnsupportedOperationException(); + } + + @Override + public IBinder asBinder() { + return this; + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstaller.java b/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstaller.java new file mode 100644 index 0000000000..b04ba0d70e --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstaller.java @@ -0,0 +1,26 @@ +package android.content.pm; + +import android.os.Binder; +import android.os.IBinder; +import android.os.IInterface; +import android.os.RemoteException; + +public interface IPackageInstaller extends IInterface { + + int createSession( + PackageInstaller.SessionParams params, + String installerPackageName, + String installerAttributionTag, + int userId) + throws RemoteException; + + IPackageInstallerSession openSession(int sessionId) throws RemoteException; + + void abandonSession(int sessionId) throws RemoteException; + + abstract class Stub extends Binder implements IPackageInstaller { + public static IPackageInstaller asInterface(IBinder binder) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstallerSession.java b/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstallerSession.java new file mode 100644 index 0000000000..6f200af9dd --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/android/content/pm/IPackageInstallerSession.java @@ -0,0 +1,14 @@ +package android.content.pm; + +import android.os.Binder; +import android.os.IBinder; +import android.os.IInterface; + +public interface IPackageInstallerSession extends IInterface { + + abstract class Stub extends Binder implements IPackageInstallerSession { + public static IPackageInstallerSession asInterface(IBinder binder) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/RemotePreferences.java b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/RemotePreferences.java new file mode 100644 index 0000000000..c822080dbe --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/RemotePreferences.java @@ -0,0 +1,238 @@ +package io.github.libxposed.service; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.RemoteException; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.WeakHashMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@SuppressWarnings("unchecked") +public final class RemotePreferences implements SharedPreferences { + + private static final String TAG = "RemotePreferences"; + private static final Object CONTENT = new Object(); + private static final Handler HANDLER = new Handler(Looper.getMainLooper()); + + private final XposedService mService; + private final String mGroup; + private final Lock mLock = new ReentrantLock(); + private final Map mMap = new ConcurrentHashMap<>(); + private final Map mListeners = + Collections.synchronizedMap(new WeakHashMap<>()); + + private volatile boolean isDeleted = false; + + private RemotePreferences(XposedService service, String group) { + this.mService = service; + this.mGroup = group; + } + + @Nullable + static RemotePreferences newInstance(XposedService service, String group) throws RemoteException { + Bundle output = service.getRaw().requestRemotePreferences(group); + if (output == null) return null; + RemotePreferences prefs = new RemotePreferences(service, group); + if (output.containsKey("map")) { + prefs.mMap.putAll((Map) output.getSerializable("map")); + } + return prefs; + } + + void setDeleted() { + this.isDeleted = true; + } + + @Override + public Map getAll() { + return new TreeMap<>(mMap); + } + + @Nullable + @Override + public String getString(String key, @Nullable String defValue) { + return (String) mMap.getOrDefault(key, defValue); + } + + @Nullable + @Override + public Set getStringSet(String key, @Nullable Set defValues) { + return (Set) mMap.getOrDefault(key, defValues); + } + + @Override + public int getInt(String key, int defValue) { + Integer v = (Integer) mMap.getOrDefault(key, defValue); + assert v != null; + return v; + } + + @Override + public long getLong(String key, long defValue) { + Long v = (Long) mMap.getOrDefault(key, defValue); + assert v != null; + return v; + } + + @Override + public float getFloat(String key, float defValue) { + Float v = (Float) mMap.getOrDefault(key, defValue); + assert v != null; + return v; + } + + @Override + public boolean getBoolean(String key, boolean defValue) { + Boolean v = (Boolean) mMap.getOrDefault(key, defValue); + assert v != null; + return v; + } + + @Override + public boolean contains(String key) { + return mMap.containsKey(key); + } + + @Override + public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) { + mListeners.put(listener, CONTENT); + } + + @Override + public void unregisterOnSharedPreferenceChangeListener( + OnSharedPreferenceChangeListener listener) { + mListeners.remove(listener); + } + + @Override + public Editor edit() { + return new Editor(); + } + + public class Editor implements SharedPreferences.Editor { + + private final HashSet mDelete = new HashSet<>(); + private final HashMap mPut = new HashMap<>(); + + private void put(String key, @NonNull Object value) { + mDelete.remove(key); + mPut.put(key, value); + } + + @Override + public SharedPreferences.Editor putString(String key, @Nullable String value) { + if (value == null) remove(key); + else put(key, value); + return this; + } + + @Override + public SharedPreferences.Editor putStringSet(String key, @Nullable Set values) { + if (values == null) remove(key); + else put(key, values); + return this; + } + + @Override + public SharedPreferences.Editor putInt(String key, int value) { + put(key, value); + return this; + } + + @Override + public SharedPreferences.Editor putLong(String key, long value) { + put(key, value); + return this; + } + + @Override + public SharedPreferences.Editor putFloat(String key, float value) { + put(key, value); + return this; + } + + @Override + public SharedPreferences.Editor putBoolean(String key, boolean value) { + put(key, value); + return this; + } + + @Override + public SharedPreferences.Editor remove(String key) { + mDelete.add(key); + mPut.remove(key); + return this; + } + + @Override + public SharedPreferences.Editor clear() { + mDelete.clear(); + mPut.clear(); + return this; + } + + private void doUpdate(boolean throwing) { + mService.deletionLock.readLock().lock(); + try { + if (isDeleted) { + throw new IllegalStateException("This preferences group has been deleted"); + } + mDelete.forEach(mMap::remove); + mMap.putAll(mPut); + List changes = new ArrayList<>(mDelete.size() + mMap.size()); + changes.addAll(mDelete); + changes.addAll(mMap.keySet()); + for (String key : changes) { + mListeners + .keySet() + .forEach(listener -> listener.onSharedPreferenceChanged(RemotePreferences.this, key)); + } + + Bundle bundle = new Bundle(); + bundle.putSerializable("delete", mDelete); + bundle.putSerializable("put", mPut); + try { + mService.getRaw().updateRemotePreferences(mGroup, bundle); + } catch (RemoteException e) { + if (throwing) { + throw new RuntimeException(e); + } else { + Log.e(TAG, "Failed to update remote preferences", e); + } + } + } finally { + mService.deletionLock.readLock().unlock(); + } + } + + @Override + public boolean commit() { + if (!mLock.tryLock()) return false; + try { + doUpdate(true); + return true; + } finally { + mLock.unlock(); + } + } + + @Override + public void apply() { + HANDLER.post(() -> doUpdate(false)); + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedProvider.java b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedProvider.java new file mode 100644 index 0000000000..f63e929353 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedProvider.java @@ -0,0 +1,73 @@ +package io.github.libxposed.service; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public final class XposedProvider extends ContentProvider { + + private static final String TAG = "XposedProvider"; + + @Override + public boolean onCreate() { + return false; + } + + @Nullable + @Override + public Cursor query( + @NonNull Uri uri, + @Nullable String[] projection, + @Nullable String selection, + @Nullable String[] selectionArgs, + @Nullable String sortOrder) { + return null; + } + + @Nullable + @Override + public String getType(@NonNull Uri uri) { + return null; + } + + @Nullable + @Override + public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { + return null; + } + + @Override + public int delete( + @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { + return 0; + } + + @Override + public int update( + @NonNull Uri uri, + @Nullable ContentValues values, + @Nullable String selection, + @Nullable String[] selectionArgs) { + return 0; + } + + @Nullable + @Override + public Bundle call(@NonNull String method, @Nullable String arg, @Nullable Bundle extras) { + if (method.equals(IXposedService.SEND_BINDER) && extras != null) { + IBinder binder = extras.getBinder("binder"); + if (binder != null) { + Log.d(TAG, "binder received: " + binder); + XposedServiceHelper.onBinderReceived(binder); + } + return new Bundle(); + } + return null; + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedService.java b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedService.java new file mode 100644 index 0000000000..12cc98bfab --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedService.java @@ -0,0 +1,372 @@ +package io.github.libxposed.service; + +import android.content.SharedPreferences; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +@SuppressWarnings("unused") +public final class XposedService { + + public static final class ServiceException extends RuntimeException { + ServiceException(String message) { + super(message); + } + + ServiceException(RemoteException e) { + super("Xposed service error", e); + } + } + + private static final Map scopeCallbacks = + new WeakHashMap<>(); + + /** Callback interface for module scope request. */ + public interface OnScopeEventListener { + /** + * Callback when the request notification / window prompted. + * + * @param packageName Package name of requested app + */ + default void onScopeRequestPrompted(String packageName) {} + + /** + * Callback when the request is approved. + * + * @param packageName Package name of requested app + */ + default void onScopeRequestApproved(String packageName) {} + + /** + * Callback when the request is denied. + * + * @param packageName Package name of requested app + */ + default void onScopeRequestDenied(String packageName) {} + + /** + * Callback when the request is timeout or revoked. + * + * @param packageName Package name of requested app + */ + default void onScopeRequestTimeout(String packageName) {} + + /** + * Callback when the request is failed. + * + * @param packageName Package name of requested app + * @param message Error message + */ + default void onScopeRequestFailed(String packageName, String message) {} + + private IXposedScopeCallback asInterface() { + return scopeCallbacks.computeIfAbsent( + this, + (listener) -> + new IXposedScopeCallback.Stub() { + @Override + public void onScopeRequestPrompted(String packageName) { + listener.onScopeRequestPrompted(packageName); + } + + @Override + public void onScopeRequestApproved(String packageName) { + listener.onScopeRequestApproved(packageName); + } + + @Override + public void onScopeRequestDenied(String packageName) { + listener.onScopeRequestDenied(packageName); + } + + @Override + public void onScopeRequestTimeout(String packageName) { + listener.onScopeRequestTimeout(packageName); + } + + @Override + public void onScopeRequestFailed(String packageName, String message) { + listener.onScopeRequestFailed(packageName, message); + } + }); + } + } + + public enum Privilege { + /** Unknown privilege value. */ + FRAMEWORK_PRIVILEGE_UNKNOWN, + + /** The framework is running as root. */ + FRAMEWORK_PRIVILEGE_ROOT, + + /** The framework is running in a container with a fake system_server. */ + FRAMEWORK_PRIVILEGE_CONTAINER, + + /** The framework is running as a different app, which may have at most shell permission. */ + FRAMEWORK_PRIVILEGE_APP, + + /** + * The framework is embedded in the hooked app, which means {@link #getRemotePreferences} will + * be null and remote file is unsupported. + */ + FRAMEWORK_PRIVILEGE_EMBEDDED + } + + private final IXposedService mService; + private final Map mRemotePrefs = new HashMap<>(); + + final ReentrantReadWriteLock deletionLock = new ReentrantReadWriteLock(); + + XposedService(IXposedService service) { + mService = service; + } + + IXposedService getRaw() { + return mService; + } + + /** + * Get the Xposed API version of current implementation. + * + * @return API version + * @throws ServiceException If the service is dead or an error occurred + */ + public int getAPIVersion() { + try { + return mService.getAPIVersion(); + } catch (RemoteException e) { + throw new ServiceException(e); + } + } + + /** + * Get the Xposed framework name of current implementation. + * + * @return Framework name + * @throws ServiceException If the service is dead or an error occurred + */ + @NonNull + public String getFrameworkName() { + try { + return mService.getFrameworkName(); + } catch (RemoteException e) { + throw new ServiceException(e); + } + } + + /** + * Get the Xposed framework version of current implementation. + * + * @return Framework version + * @throws ServiceException If the service is dead or an error occurred + */ + @NonNull + public String getFrameworkVersion() { + try { + return mService.getFrameworkVersion(); + } catch (RemoteException e) { + throw new ServiceException(e); + } + } + + /** + * Get the Xposed framework version code of current implementation. + * + * @return Framework version code + * @throws ServiceException If the service is dead or an error occurred + */ + public long getFrameworkVersionCode() { + try { + return mService.getFrameworkVersionCode(); + } catch (RemoteException e) { + throw new ServiceException(e); + } + } + + /** + * Get the Xposed framework privilege of current implementation. + * + * @return Framework privilege + * @throws ServiceException If the service is dead or an error occurred + */ + @NonNull + public Privilege getFrameworkPrivilege() { + try { + int value = mService.getFrameworkPrivilege(); + return (value >= 0 && value <= 3) + ? Privilege.values()[value + 1] + : Privilege.FRAMEWORK_PRIVILEGE_UNKNOWN; + } catch (RemoteException e) { + throw new ServiceException(e); + } + } + + /** + * Get the application scope of current module. + * + * @return Module scope + * @throws ServiceException If the service is dead or an error occurred + */ + @NonNull + public List getScope() { + try { + return mService.getScope(); + } catch (RemoteException e) { + throw new ServiceException(e); + } + } + + /** + * Request to add a new app to the module scope. + * + * @param packageName Package name of the app to be added + * @param callback Callback to be invoked when the request is completed or error occurred + * @throws ServiceException If the service is dead or an error occurred + */ + public void requestScope(@NonNull String packageName, @NonNull OnScopeEventListener callback) { + try { + mService.requestScope(packageName, callback.asInterface()); + } catch (RemoteException e) { + throw new ServiceException(e); + } + } + + /** + * Remove an app from the module scope. + * + * @param packageName Package name of the app to be added + * @return null if successful, or non-null with error message + * @throws ServiceException If the service is dead or an error occurred + */ + @Nullable + public String removeScope(@NonNull String packageName) { + try { + return mService.removeScope(packageName); + } catch (RemoteException e) { + throw new ServiceException(e); + } + } + + /** + * Get remote preferences from Xposed framework. If the group does not exist, it will be created. + * + * @param group Group name + * @return The preferences + * @throws ServiceException If the service is dead or an error occurred + * @throws UnsupportedOperationException If the framework is embedded + */ + @NonNull + public SharedPreferences getRemotePreferences(@NonNull String group) { + return mRemotePrefs.computeIfAbsent( + group, + k -> { + try { + RemotePreferences instance = RemotePreferences.newInstance(this, k); + if (instance == null) { + throw new ServiceException("Framework returns null"); + } + return instance; + } catch (RemoteException e) { + if (e.getCause() instanceof UnsupportedOperationException cause) { + throw cause; + } + throw new ServiceException(e); + } + }); + } + + /** + * Delete a group of remote preferences. + * + * @param group Group name + * @throws ServiceException If the service is dead or an error occurred + * @throws UnsupportedOperationException If the framework is embedded + */ + public void deleteRemotePreferences(@NonNull String group) { + deletionLock.writeLock().lock(); + try { + mService.deleteRemotePreferences(group); + mRemotePrefs.computeIfPresent( + group, + (k, v) -> { + v.setDeleted(); + return null; + }); + } catch (RemoteException e) { + if (e.getCause() instanceof UnsupportedOperationException cause) { + throw cause; + } + throw new ServiceException(e); + } finally { + deletionLock.writeLock().unlock(); + } + } + + /** + * List all files in the module's shared data directory. + * + * @return The file list + * @throws ServiceException If the service is dead or an error occurred + * @throws UnsupportedOperationException If the framework is embedded + */ + @NonNull + public String[] listRemoteFiles() { + try { + String[] files = mService.listRemoteFiles(); + if (files == null) throw new ServiceException("Framework returns null"); + return files; + } catch (RemoteException e) { + if (e.getCause() instanceof UnsupportedOperationException cause) { + throw cause; + } + throw new ServiceException(e); + } + } + + /** + * Open a file in the module's shared data directory. The file will be created if not exists. + * + * @param name File name, must not contain path separators and . or .. + * @return The file descriptor + * @throws ServiceException If the service is dead or an error occurred + * @throws UnsupportedOperationException If the framework is embedded + */ + @NonNull + public ParcelFileDescriptor openRemoteFile(@NonNull String name) { + try { + ParcelFileDescriptor file = mService.openRemoteFile(name); + if (file == null) throw new ServiceException("Framework returns null"); + return file; + } catch (RemoteException e) { + if (e.getCause() instanceof UnsupportedOperationException cause) { + throw cause; + } + throw new ServiceException(e); + } + } + + /** + * Delete a file in the module's shared data directory. + * + * @param name File name, must not contain path separators and . or .. + * @return true if successful, false if the file does not exist + * @throws ServiceException If the service is dead or an error occurred + * @throws UnsupportedOperationException If the framework is embedded + */ + public boolean deleteRemoteFile(@NonNull String name) { + try { + return mService.deleteRemoteFile(name); + } catch (RemoteException e) { + if (e.getCause() instanceof UnsupportedOperationException cause) { + throw cause; + } + throw new ServiceException(e); + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java new file mode 100644 index 0000000000..0936ad129d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/github/libxposed/service/XposedServiceHelper.java @@ -0,0 +1,72 @@ +package io.github.libxposed.service; + +import android.os.IBinder; +import android.util.Log; +import androidx.annotation.NonNull; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; + +@SuppressWarnings("unused") +public final class XposedServiceHelper { + + /** Callback interface for Xposed service. */ + public interface OnServiceListener { + /** + * Callback when the service is connected.
+ * This method could be called multiple times if multiple Xposed frameworks exist. + * + * @param service Service instance + */ + void onServiceBind(@NonNull XposedService service); + + /** Callback when the service is dead. */ + void onServiceDied(@NonNull XposedService service); + } + + private static final String TAG = "XposedServiceHelper"; + private static final Set mCache = new HashSet<>(); + private static OnServiceListener mListener = null; + + static void onBinderReceived(IBinder binder) { + if (binder == null) return; + synchronized (mCache) { + try { + XposedService service = new XposedService(IXposedService.Stub.asInterface(binder)); + if (mListener == null) { + mCache.add(service); + } else { + binder.linkToDeath(() -> mListener.onServiceDied(service), 0); + mListener.onServiceBind(service); + } + } catch (Throwable t) { + Log.e(TAG, "onBinderReceived", t); + } + } + } + + /** + * Register a ServiceListener to receive service binders from Xposed frameworks.
+ * This method should only be called once. + * + * @param listener Listener to register + */ + public static void registerListener(OnServiceListener listener) { + synchronized (mCache) { + mListener = listener; + if (!mCache.isEmpty()) { + for (Iterator it = mCache.iterator(); it.hasNext(); ) { + try { + XposedService service = it.next(); + service.getRaw().asBinder().linkToDeath(() -> mListener.onServiceDied(service), 0); + mListener.onServiceBind(service); + } catch (Throwable t) { + Log.e(TAG, "registerListener", t); + it.remove(); + } + } + mCache.clear(); + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/Application.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/Application.kt index 8c3144bb5f..02b2467b9a 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/Application.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/Application.kt @@ -16,6 +16,11 @@ import io.nekohasekai.libbox.SetupOptions import io.nekohasekai.sfa.bg.AppChangeReceiver import io.nekohasekai.sfa.bg.UpdateProfileWork import io.nekohasekai.sfa.constant.Bugs +import io.nekohasekai.sfa.utils.AppLifecycleObserver +import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier +import io.nekohasekai.sfa.utils.HookStatusClient +import io.nekohasekai.sfa.utils.PrivilegeSettingsClient +import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -24,7 +29,6 @@ import java.util.Locale import io.nekohasekai.sfa.Application as BoxApplication class Application : Application() { - override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) application = this @@ -32,21 +36,30 @@ class Application : Application() { override fun onCreate() { super.onCreate() + AppLifecycleObserver.register(this) - Seq.setContext(this) +// Seq.setContext(this) Libbox.setLocale(Locale.getDefault().toLanguageTag().replace("-", "_")) + HookStatusClient.register(this) + PrivilegeSettingsClient.register(this) @Suppress("OPT_IN_USAGE") GlobalScope.launch(Dispatchers.IO) { initialize() UpdateProfileWork.reconfigureUpdater() + HookModuleUpdateNotifier.sync(this@Application) } - registerReceiver(AppChangeReceiver(), IntentFilter().apply { - addAction(Intent.ACTION_PACKAGE_ADDED) - addDataScheme("package") - }) - + if (Vendor.isPerAppProxyAvailable()) { + registerReceiver( + AppChangeReceiver(), + IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REPLACED) + addDataScheme("package") + }, + ) + } } private fun initialize() { @@ -56,12 +69,16 @@ class Application : Application() { 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 - }) + 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) } @@ -75,5 +92,4 @@ class Application : Application() { val wifiManager by lazy { application.getSystemService()!! } val clipboard by lazy { application.getSystemService()!! } } - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt new file mode 100644 index 0000000000..b293dec27b --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/WorkingDirectoryProvider.kt @@ -0,0 +1,174 @@ +package io.nekohasekai.sfa + +import android.database.Cursor +import android.database.MatrixCursor +import android.os.CancellationSignal +import android.os.ParcelFileDescriptor +import android.provider.DocumentsContract +import android.provider.DocumentsProvider +import android.webkit.MimeTypeMap +import java.io.File + +class WorkingDirectoryProvider : DocumentsProvider() { + + companion object { + private const val ROOT_ID = "working_directory" + private const val ROOT_DOC_ID = "root" + + private val DEFAULT_ROOT_PROJECTION = arrayOf( + DocumentsContract.Root.COLUMN_ROOT_ID, + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.COLUMN_ICON, + DocumentsContract.Root.COLUMN_TITLE, + DocumentsContract.Root.COLUMN_SUMMARY, + DocumentsContract.Root.COLUMN_DOCUMENT_ID, + ) + + private val DEFAULT_DOCUMENT_PROJECTION = arrayOf( + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_MIME_TYPE, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_FLAGS, + DocumentsContract.Document.COLUMN_SIZE, + ) + } + + private val baseDir: File + get() = context!!.getExternalFilesDir(null)!! + + override fun onCreate(): Boolean = true + + override fun queryRoots(projection: Array?): Cursor { + val result = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) + result.newRow().apply { + add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT_ID) + add( + DocumentsContract.Root.COLUMN_FLAGS, + DocumentsContract.Root.FLAG_SUPPORTS_CREATE or + DocumentsContract.Root.FLAG_SUPPORTS_IS_CHILD, + ) + add(DocumentsContract.Root.COLUMN_ICON, R.mipmap.ic_launcher) + add(DocumentsContract.Root.COLUMN_TITLE, context!!.getString(R.string.app_name)) + add(DocumentsContract.Root.COLUMN_SUMMARY, context!!.getString(R.string.working_directory)) + add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT_DOC_ID) + } + return result + } + + override fun queryDocument(documentId: String, projection: Array?): Cursor { + val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + val file = getFileForDocId(documentId) + includeFile(result, documentId, file) + return result + } + + override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor { + val result = MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION) + val parent = getFileForDocId(parentDocumentId) + parent.listFiles()?.forEach { file -> + includeFile(result, getDocIdForFile(file), file) + } + return result + } + + override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor { + val file = getFileForDocId(documentId) + val accessMode = ParcelFileDescriptor.parseMode(mode) + return ParcelFileDescriptor.open(file, accessMode) + } + + override fun createDocument(parentDocumentId: String, mimeType: String, displayName: String): String { + val parent = getFileForDocId(parentDocumentId) + val file = File(parent, displayName) + + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + file.mkdirs() + } else { + file.createNewFile() + } + + return getDocIdForFile(file) + } + + override fun deleteDocument(documentId: String) { + val file = getFileForDocId(documentId) + if (file.isDirectory) { + file.deleteRecursively() + } else { + file.delete() + } + } + + override fun renameDocument(documentId: String, displayName: String): String { + val file = getFileForDocId(documentId) + val newFile = File(file.parentFile, displayName) + file.renameTo(newFile) + return getDocIdForFile(newFile) + } + + override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean { + val parent = getFileForDocId(parentDocumentId) + val child = getFileForDocId(documentId) + return child.absolutePath.startsWith(parent.absolutePath) + } + + private fun getFileForDocId(documentId: String): File { + if (documentId == ROOT_DOC_ID) { + return baseDir + } + return File(baseDir, documentId) + } + + private fun getDocIdForFile(file: File): String { + val path = file.absolutePath + val basePath = baseDir.absolutePath + + return if (path == basePath) { + ROOT_DOC_ID + } else { + path.removePrefix("$basePath/") + } + } + + private fun includeFile(result: MatrixCursor, documentId: String, file: File) { + var flags = 0 + + if (file.isDirectory) { + flags = flags or DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE + } else { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_WRITE + } + + if (file.parentFile?.canWrite() == true) { + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE + flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME + } + + val mimeType = if (file.isDirectory) { + DocumentsContract.Document.MIME_TYPE_DIR + } else { + getMimeType(file) + } + + result.newRow().apply { + add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, documentId) + add(DocumentsContract.Document.COLUMN_MIME_TYPE, mimeType) + add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name) + add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified()) + add(DocumentsContract.Document.COLUMN_FLAGS, flags) + add(DocumentsContract.Document.COLUMN_SIZE, file.length()) + } + } + + private fun getMimeType(file: File): String { + val extension = file.extension.lowercase() + if (extension.isNotEmpty()) { + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + if (mimeType != null) { + return mimeType + } + } + return "application/octet-stream" + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt index 3d251b4fd8..8b563017cf 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/AppChangeReceiver.kt @@ -4,48 +4,49 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log +import android.widget.Toast +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ui.profileoverride.PerAppProxyActivity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class AppChangeReceiver : BroadcastReceiver() { - companion object { private const val TAG = "AppChangeReceiver" } override fun onReceive(context: Context, intent: Intent) { Log.d(TAG, "onReceive: ${intent.action}") - checkUpdate(intent) - } - - private fun checkUpdate(intent: Intent) { if (!Settings.perAppProxyEnabled) { Log.d(TAG, "per app proxy disabled") return } - if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) { - Log.d(TAG, "skip app update") + if (!Settings.perAppProxyManagedMode) { + Log.d(TAG, "managed mode disabled") return } - val perAppProxyUpdateOnChange = Settings.perAppProxyUpdateOnChange - if (perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_DISABLED) { - Log.d(TAG, "update on change disabled") - return - } - val packageName = intent.dataString?.substringAfter("package:") - if (packageName.isNullOrBlank()) { - Log.d(TAG, "missing package name in intent") - return - } - val isChinaApp = PerAppProxyActivity.scanChinaPackage(packageName) - Log.d(TAG, "scan china app result for $packageName: $isChinaApp") - if ((perAppProxyUpdateOnChange == Settings.PER_APP_PROXY_INCLUDE) xor !isChinaApp) { - Settings.perAppProxyList += packageName - Log.d(TAG, "added to list") - } else { - Settings.perAppProxyList -= packageName - Log.d(TAG, "removed from list") + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + rescanAllApps() + } catch (e: Exception) { + Log.e(TAG, "Failed to rescan apps", e) + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.error_title, Toast.LENGTH_SHORT).show() + } + } finally { + pendingResult.finish() + } } } -} \ No newline at end of file + private suspend fun rescanAllApps() { + Log.d(TAG, "rescanning all apps") + val chinaApps = PerAppProxyScanner.scanAllChinaApps() + Settings.perAppProxyManagedList = chinaApps + Log.d(TAG, "rescan complete, found ${chinaApps.size} china apps") + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt index f4dc8f024d..013406c997 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BootReceiver.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BootReceiver : BroadcastReceiver() { - @OptIn(DelicateCoroutinesApi::class) override fun onReceive(context: Context, intent: Intent) { when (intent.action) { @@ -28,5 +27,4 @@ class BootReceiver : BroadcastReceiver() { } } } - } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt index 1206ea5230..1761e650f7 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/BoxService.kt @@ -13,27 +13,29 @@ import android.os.Build import android.os.IBinder import android.os.ParcelFileDescriptor import android.os.PowerManager +import android.util.Log import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.lifecycle.MutableLiveData import go.Seq -import io.nekohasekai.libbox.BoxService import io.nekohasekai.libbox.CommandServer import io.nekohasekai.libbox.CommandServerHandler import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.Notification +import io.nekohasekai.libbox.OverrideOptions import io.nekohasekai.libbox.PlatformInterface import io.nekohasekai.libbox.SystemProxyStatus import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.MainActivity import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Alert import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.ProfileManager import io.nekohasekai.sfa.database.Settings import io.nekohasekai.sfa.ktx.hasPermission -import io.nekohasekai.sfa.ui.MainActivity +import io.nekohasekai.sfa.vendor.Vendor import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -42,26 +44,26 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.File -class BoxService( - private val service: Service, private val platformInterface: PlatformInterface -) : CommandServerHandler { - +class BoxService(private val service: Service, private val platformInterface: PlatformInterface) : CommandServerHandler { companion object { + private const val PROFILE_UPDATE_INTERVAL = 15L * 60 * 1000 // 15 minutes in milliseconds + private const val TAG = "BoxService" fun start() { - val intent = runBlocking { - withContext(Dispatchers.IO) { - Intent(Application.application, Settings.serviceClass()) + val intent = + runBlocking { + withContext(Dispatchers.IO) { + Intent(Application.application, Settings.serviceClass()) + } } - } ContextCompat.startForegroundService(Application.application, intent) } fun stop() { Application.application.sendBroadcast( Intent(Action.SERVICE_CLOSE).setPackage( - Application.application.packageName - ) + Application.application.packageName, + ), ) } } @@ -71,33 +73,34 @@ class BoxService( private val status = MutableLiveData(Status.Stopped) private val binder = ServiceBinder(status) private val notification = ServiceNotification(status, service) - private var boxService: BoxService? = null - private var commandServer: CommandServer? = null + private lateinit var commandServer: CommandServer + private var receiverRegistered = false - private val receiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - Action.SERVICE_CLOSE -> { - stopService() - } + private val receiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Action.SERVICE_CLOSE -> { + stopService() + } - - PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - serviceUpdateIdleMode() + PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + serviceUpdateIdleMode() + } } } } } - } private fun startCommandServer() { - val commandServer = CommandServer(this, 300) + val commandServer = CommandServer(this, platformInterface) commandServer.start() this.commandServer = commandServer } private var lastProfileName = "" + private suspend fun startService() { try { withContext(Dispatchers.Main) { @@ -128,32 +131,43 @@ class BoxService( } DefaultNetworkMonitor.start() - Libbox.setMemoryLimit(!Settings.disableMemoryLimit) - val newService = try { - Libbox.newService(content, platformInterface) + try { + commandServer.startOrReloadService( + content, + OverrideOptions().apply { + autoRedirect = Settings.autoRedirect + if (Vendor.isPerAppProxyAvailable() && Settings.perAppProxyEnabled) { + val appList = Settings.getEffectivePerAppProxyList() + if (Settings.getEffectivePerAppProxyMode() == Settings.PER_APP_PROXY_INCLUDE) { + includePackage = + PlatformInterfaceWrapper.StringArray((appList + Application.application.packageName).iterator()) + } else { + excludePackage = + PlatformInterfaceWrapper.StringArray((appList - Application.application.packageName).iterator()) + } + } + }, + ) } catch (e: Exception) { stopAndAlert(Alert.CreateService, e.message) return } - newService.start() - - if (newService.needWIFIState()) { - val wifiPermission = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - android.Manifest.permission.ACCESS_FINE_LOCATION - } else { - android.Manifest.permission.ACCESS_BACKGROUND_LOCATION - } + if (commandServer.needWIFIState()) { + val wifiPermission = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + android.Manifest.permission.ACCESS_FINE_LOCATION + } else { + android.Manifest.permission.ACCESS_BACKGROUND_LOCATION + } if (!service.hasPermission(wifiPermission)) { - newService.close() + closeService() stopAndAlert(Alert.RequestLocationPermission) return } } - boxService = newService - commandServer?.setService(boxService) status.postValue(Status.Started) withContext(Dispatchers.Main) { notification.show(lastProfileName, R.string.status_started) @@ -165,7 +179,7 @@ class BoxService( } } - override fun serviceReload() { + override fun serviceStop() { notification.close() status.postValue(Status.Starting) val pfd = fileDescriptor @@ -173,27 +187,70 @@ class BoxService( pfd.close() fileDescriptor = null } - boxService?.apply { - runCatching { - close() - }.onFailure { - writeLog("service: error when closing: $it") - } - Seq.destroyRef(refnum) - } - commandServer?.setService(null) - commandServer?.resetLog() - boxService = null + closeService() + } + + override fun serviceReload() { runBlocking { - startService() + serviceReload0() } } - override fun postServiceClose() { - // Not used on Android + suspend fun serviceReload0() { + val selectedProfileId = Settings.selectedProfile + if (selectedProfileId == -1L) { + stopAndAlert(Alert.EmptyConfiguration) + return + } + + val profile = ProfileManager.get(selectedProfileId) + if (profile == null) { + stopAndAlert(Alert.EmptyConfiguration) + return + } + + val content = File(profile.typed.path).readText() + if (content.isBlank()) { + stopAndAlert(Alert.EmptyConfiguration) + return + } + lastProfileName = profile.name + try { + commandServer.startOrReloadService( + content, + OverrideOptions().apply { + autoRedirect = Settings.autoRedirect + if (Vendor.isPerAppProxyAvailable() && Settings.perAppProxyEnabled) { + val appList = Settings.getEffectivePerAppProxyList() + if (Settings.getEffectivePerAppProxyMode() == Settings.PER_APP_PROXY_INCLUDE) { + includePackage = PlatformInterfaceWrapper.StringArray((appList + Application.application.packageName).iterator()) + } else { + excludePackage = PlatformInterfaceWrapper.StringArray((appList - Application.application.packageName).iterator()) + } + } + }, + ) + } catch (e: Exception) { + stopAndAlert(Alert.CreateService, e.message) + return + } + + if (commandServer.needWIFIState()) { + val wifiPermission = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + android.Manifest.permission.ACCESS_FINE_LOCATION + } else { + android.Manifest.permission.ACCESS_BACKGROUND_LOCATION + } + if (!service.hasPermission(wifiPermission)) { + closeService() + stopAndAlert(Alert.RequestLocationPermission) + return + } + } } - override fun getSystemProxyStatus(): SystemProxyStatus { + override fun getSystemProxyStatus(): SystemProxyStatus? { val status = SystemProxyStatus() if (service is VPNService) { status.available = service.systemProxyAvailable @@ -209,9 +266,9 @@ class BoxService( @RequiresApi(Build.VERSION_CODES.M) private fun serviceUpdateIdleMode() { if (Application.powerManager.isDeviceIdleMode) { - boxService?.pause() + commandServer.pause() } else { - boxService?.wake() + commandServer.wake() } } @@ -230,23 +287,12 @@ class BoxService( pfd.close() fileDescriptor = null } - boxService?.apply { - runCatching { - close() - }.onFailure { - writeLog("service: error when closing: $it") - } - Seq.destroyRef(refnum) - } - commandServer?.setService(null) - boxService = null DefaultNetworkMonitor.stop() - - commandServer?.apply { + closeService() + commandServer.apply { close() - Seq.destroyRef(refnum) +// Seq.destroyRef(refnum) } - commandServer = null Settings.startedByUser = false withContext(Dispatchers.Main) { status.value = Status.Stopped @@ -255,6 +301,14 @@ class BoxService( } } + private fun closeService() { + runCatching { + commandServer.closeService() + }.onFailure { + commandServer.setError("android: close service: ${it.message}") + } + } + private suspend fun stopAndAlert(type: Alert, message: String? = null) { Settings.startedByUser = false withContext(Dispatchers.Main) { @@ -277,12 +331,17 @@ class BoxService( status.value = Status.Starting if (!receiverRegistered) { - ContextCompat.registerReceiver(service, receiver, IntentFilter().apply { - addAction(Action.SERVICE_CLOSE) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) - } - }, ContextCompat.RECEIVER_NOT_EXPORTED) + ContextCompat.registerReceiver( + service, + receiver, + IntentFilter().apply { + addAction(Action.SERVICE_CLOSE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED) + } + }, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) receiverRegistered = true } @@ -299,9 +358,7 @@ class BoxService( return Service.START_NOT_STICKY } - internal fun onBind(): IBinder { - return binder - } + internal fun onBind(): IBinder = binder internal fun onDestroy() { binder.close() @@ -311,20 +368,13 @@ class BoxService( stopService() } - internal fun writeLog(message: String) { - commandServer?.writeMessage(message) - } - internal fun sendNotification(notification: Notification) { val builder = NotificationCompat.Builder(service, notification.identifier).setShowWhen(false) - .setContentTitle(notification.title) - .setContentText(notification.body) - .setOnlyAlertOnce(true) - .setSmallIcon(R.drawable.ic_menu) + .setContentTitle(notification.title).setContentText(notification.body) + .setOnlyAlertOnce(true).setSmallIcon(R.drawable.ic_menu) .setCategory(NotificationCompat.CATEGORY_EVENT) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true) if (!notification.subtitle.isNullOrBlank()) { builder.setContentInfo(notification.subtitle) } @@ -334,13 +384,14 @@ class BoxService( service, 0, Intent( - service, MainActivity::class.java + service, + MainActivity::class.java, ).apply { setAction(Action.OPEN_URL).setData(Uri.parse(notification.openURL)) setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) }, ServiceNotification.flags, - ) + ), ) } GlobalScope.launch(Dispatchers.Main) { @@ -349,11 +400,15 @@ class BoxService( NotificationChannel( notification.identifier, notification.typeName, - NotificationManager.IMPORTANCE_HIGH - ) + NotificationManager.IMPORTANCE_HIGH, + ), ) } Application.notification.notify(notification.typeID, builder.build()) } } -} \ No newline at end of file + + override fun writeDebugMessage(message: String?) { + Log.d("sing-box", message!!) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt new file mode 100644 index 0000000000..a45c8d2d00 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DebugInfoExporter.kt @@ -0,0 +1,312 @@ +package io.nekohasekai.sfa.bg + +import android.content.Context +import android.util.Log +import io.nekohasekai.sfa.utils.HookErrorClient +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +object DebugInfoExporter { + private const val TAG = "DebugInfoExporter" + + fun export(context: Context, outputPath: String, packageName: String): String { + Log.i(TAG, "export start: output=$outputPath, package=$packageName") + val outFile = File(outputPath) + if (!outFile.name.lowercase(Locale.US).endsWith(".zip")) { + Log.e(TAG, "export failed: output path must end with .zip") + throw IllegalArgumentException("output path must end with .zip") + } + val parent = outFile.parentFile!! + if (!parent.exists()) { + Log.i(TAG, "creating output directory: ${parent.path}") + if (!parent.mkdirs()) { + Log.e(TAG, "export failed: failed to create output directory: ${parent.path}") + throw IllegalStateException("failed to create output directory") + } + } + val warnings = mutableListOf() + var entriesAdded = 0 + try { + ZipOutputStream(BufferedOutputStream(FileOutputStream(outFile))).use { zip -> + Log.i(TAG, "adding export_info.txt") + addTextEntry(zip, "system/export_info.txt", buildExportInfo(context, packageName)) + entriesAdded++ + Log.i(TAG, "adding framework entries") + val frameworkCount = addFrameworkEntries(zip, warnings) + entriesAdded += frameworkCount + Log.i(TAG, "added $frameworkCount framework entries") + Log.i(TAG, "adding apex entries") + val apexCount = addApexEntries(zip, warnings) + entriesAdded += apexCount + Log.i(TAG, "added $apexCount apex entries") + Log.i(TAG, "adding log entries") + val logCount = addLogEntries(zip, warnings, context) + entriesAdded += logCount + Log.i(TAG, "added $logCount log entries") + Log.i(TAG, "adding system entries") + val systemCount = addSystemEntries(zip, warnings, packageName) + entriesAdded += systemCount + Log.i(TAG, "added $systemCount system entries") + if (warnings.isNotEmpty()) { + addTextEntry(zip, "logs/debug_export.txt", warnings.joinToString("\n")) + entriesAdded++ + } + } + Log.i(TAG, "zip closed, total entries: $entriesAdded, file size: ${outFile.length()}") + } catch (e: Throwable) { + outFile.delete() + val error = buildError("zip", "export failed", e, warnings, outputPath) + Log.e(TAG, error, e) + throw e + } + if (outFile.length() == 0L) { + val error = "output file is empty after writing $entriesAdded entries" + Log.e(TAG, error) + outFile.delete() + throw IllegalStateException(error) + } + outFile.setReadable(true, false) + if (warnings.isNotEmpty()) { + Log.w(TAG, "export finished with ${warnings.size} warnings, output size: ${outFile.length()}") + } else { + Log.i(TAG, "export finished: output=$outputPath, size=${outFile.length()}") + } + return outFile.absolutePath + } + + private fun buildExportInfo(context: Context, packageName: String): String { + val sb = StringBuilder() + sb.append("package=").append(packageName).append('\n') + sb.append("timestamp=").append(System.currentTimeMillis()).append('\n') + sb.append("context_class=").append(context.javaClass.name).append('\n') + return sb.toString() + } + + private fun addFrameworkEntries(zip: ZipOutputStream, warnings: MutableList): Int { + var count = 0 + val roots = + listOf( + File("/system/framework"), + File("/system_ext/framework"), + File("/product/framework"), + File("/vendor/framework"), + ) + val targetFiles = setOf("framework.jar", "services.jar") + for (root in roots) { + if (!root.isDirectory) continue + val destPrefix = "framework/${root.name}" + val files = root.listFiles() ?: emptyArray() + for (file in files) { + if (!file.isFile) continue + if (file.name !in targetFiles) continue + if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) { + count++ + } + } + } + return count + } + + private fun addApexEntries(zip: ZipOutputStream, warnings: MutableList): Int { + var count = 0 + val tetheringApex = File("/apex/com.android.tethering/javalib") + if (!tetheringApex.isDirectory) return 0 + val destPrefix = "framework/apex_com.android.tethering" + val files = tetheringApex.listFiles() ?: emptyArray() + for (file in files) { + if (!file.isFile) continue + if (!file.name.lowercase(Locale.US).endsWith(".jar")) continue + if (addFileEntry(zip, file, "$destPrefix/${file.name}", warnings)) { + count++ + } + } + return count + } + + private fun addLogEntries(zip: ZipOutputStream, warnings: MutableList, context: Context): Int { + var count = 0 + if (streamCommandToZip(zip, "logs/logcat.txt", warnings, listOf("logcat", "-d", "-b", "all")) != null) count++ + if (streamCommandToZip(zip, "logs/dmesg.txt", warnings, listOf("dmesg")) != null) count++ + val serviceLogsResult = HookErrorClient.query(context) + if (serviceLogsResult.logs.isNotEmpty()) { + val formatted = formatLogEntries(serviceLogsResult.logs) + addTextEntry(zip, "logs/service_logs.txt", formatted) + count++ + } else if (serviceLogsResult.failure != null) { + warnings.add("service logs: ${serviceLogsResult.failure}${serviceLogsResult.detail?.let { " ($it)" } ?: ""}") + } + val lspdDir = File("/data/adb/lspd/log") + if (lspdDir.isDirectory) { + val files = lspdDir.listFiles() ?: emptyArray() + for (file in files) { + if (!file.isFile) continue + if (addFileEntry(zip, file, "logs/lspd/${file.name}", warnings)) count++ + } + } else { + warnings.add("lspd logs not found: /data/adb/lspd/log") + } + return count + } + + private fun formatLogEntries(entries: List): String { + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + return entries.joinToString("\n---\n") { entry -> + val levelName = when (entry.level) { + LogEntry.LEVEL_DEBUG -> "DEBUG" + LogEntry.LEVEL_INFO -> "INFO" + LogEntry.LEVEL_WARN -> "WARN" + LogEntry.LEVEL_ERROR -> "ERROR" + else -> "UNKNOWN" + } + val timestamp = dateFormat.format(Date(entry.timestamp)) + buildString { + append(levelName).append("[").append(timestamp).append("] ") + append("[").append(entry.source).append("]: ") + append(entry.message) + if (!entry.stackTrace.isNullOrEmpty()) { + append("\n").append(entry.stackTrace) + } + } + } + } + + private fun addSystemEntries(zip: ZipOutputStream, warnings: MutableList, packageName: String): Int { + var count = 0 + if (streamCommandToZip(zip, "system/getprop.txt", warnings, listOf("getprop")) != null) count++ + if (streamCommandToZip(zip, "system/uname.txt", warnings, listOf("uname", "-a")) != null) count++ + if (streamCommandToZip(zip, "system/id.txt", warnings, listOf("id")) != null) count++ + if (addFileEntry(zip, File("/proc/version"), "system/proc_version.txt", warnings)) count++ + if (addFileEntry(zip, File("/proc/cpuinfo"), "system/cpuinfo.txt", warnings)) count++ + if (addFileEntry(zip, File("/proc/meminfo"), "system/meminfo.txt", warnings)) count++ + if (addFileEntry(zip, File("/proc/pressure/cpu"), "system/pressure_cpu.txt", warnings)) count++ + if (addFileEntry(zip, File("/proc/pressure/memory"), "system/pressure_memory.txt", warnings)) count++ + if (addFileEntry(zip, File("/proc/pressure/io"), "system/pressure_io.txt", warnings)) count++ + val cmdPackages = + streamCommandToZip( + zip, + "system/packages_cmd.txt", + warnings, + listOf("cmd", "package", "list", "packages", "-f"), + ) + if (cmdPackages != null) count++ + if ((cmdPackages == null || cmdPackages.bytes == 0L) && (cmdPackages?.exitCode ?: 1) != 0) { + if (streamCommandToZip( + zip, + "system/packages_pm.txt", + warnings, + listOf("pm", "list", "packages", "-f"), + ) != null + ) { + count++ + } + } + if (streamCommandToZip( + zip, + "system/dumpsys_package_$packageName.txt", + warnings, + listOf("dumpsys", "package", packageName), + ) != null + ) { + count++ + } + return count + } + + private fun addFileEntry(zip: ZipOutputStream, file: File, entryName: String, warnings: MutableList): Boolean { + if (!file.isFile) { + warnings.add("missing file: ${file.path}") + return false + } + try { + val entry = ZipEntry(entryName) + zip.putNextEntry(entry) + BufferedInputStream(FileInputStream(file)).use { input -> + val buffer = ByteArray(16 * 1024) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + zip.write(buffer, 0, read) + } + } + zip.closeEntry() + return true + } catch (e: Throwable) { + warnings.add("zip failed ${file.path}: ${e.message}") + return false + } + } + + private fun addTextEntry(zip: ZipOutputStream, entryName: String, content: String) { + val entry = ZipEntry(entryName) + zip.putNextEntry(entry) + val bytes = content.toByteArray() + zip.write(bytes) + zip.closeEntry() + } + + private data class CommandResult(val exitCode: Int, val bytes: Long) + + private fun streamCommandToZip( + zip: ZipOutputStream, + entryName: String, + warnings: MutableList, + command: List, + ): CommandResult? = try { + val process = ProcessBuilder(command).redirectErrorStream(true).start() + val entry = ZipEntry(entryName) + zip.putNextEntry(entry) + var bytes = 0L + process.inputStream.use { input -> + val buffer = ByteArray(16 * 1024) + while (true) { + val read = input.read(buffer) + if (read <= 0) break + zip.write(buffer, 0, read) + bytes += read + } + } + zip.closeEntry() + val code = process.waitFor() + if (code != 0) { + warnings.add("command failed (${command.joinToString(" ")}): exit=$code") + } + CommandResult(code, bytes) + } catch (e: Throwable) { + warnings.add("command failed (${command.joinToString(" ")}): ${e.message}") + runCatching { zip.closeEntry() } + null + } + + private fun buildError(stage: String, detail: String, throwable: Throwable?, warnings: List, outputPath: String?): String { + val sb = StringBuilder() + sb.append("stage=").append(stage).append('\n') + if (!outputPath.isNullOrBlank()) { + sb.append("output=").append(outputPath).append('\n') + } + if (detail.isNotBlank()) { + sb.append("detail=").append(detail).append('\n') + } + if (throwable != null) { + sb.append("exception=").append(throwable.javaClass.name) + .append(": ").append(throwable.message ?: "").append('\n') + val sw = StringWriter() + throwable.printStackTrace(PrintWriter(sw)) + sb.append(sw.toString()) + } + if (warnings.isNotEmpty()) { + if (!sb.endsWith('\n')) sb.append('\n') + sb.append("warnings:\n").append(warnings.joinToString("\n")) + } + return sb.toString().trimEnd() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt index 239daae6c4..d7dfc2b4e0 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkListener.kt @@ -40,6 +40,7 @@ import kotlinx.coroutines.runBlocking object DefaultNetworkListener { private sealed class NetworkMessage { class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage() + class Get : NetworkMessage() { val response = CompletableDeferred() } @@ -47,69 +48,88 @@ object DefaultNetworkListener { class Stop(val key: Any) : NetworkMessage() class Put(val network: Network) : NetworkMessage() + class Update(val network: Network) : NetworkMessage() + class Lost(val network: Network) : NetworkMessage() } @OptIn(DelicateCoroutinesApi::class, ObsoleteCoroutinesApi::class) - private val networkActor = GlobalScope.actor(Dispatchers.Unconfined) { - val listeners = mutableMapOf Unit>() - var network: Network? = null - val pendingRequests = arrayListOf() - for (message in channel) when (message) { - is NetworkMessage.Start -> { - if (listeners.isEmpty()) register() - listeners[message.key] = message.listener - if (network != null) message.listener(network) - } + private val networkActor = + GlobalScope.actor(Dispatchers.Unconfined) { + val listeners = mutableMapOf Unit>() + var network: Network? = null + val pendingRequests = arrayListOf() + for (message in channel) { + when (message) { + is NetworkMessage.Start -> { + if (listeners.isEmpty()) register() + listeners[message.key] = message.listener + if (network != null) message.listener(network) + } - is NetworkMessage.Get -> { - check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" } - if (network == null) pendingRequests += message else message.response.complete( - network - ) - } + is NetworkMessage.Get -> { + check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" } + if (network == null) { + pendingRequests += message + } else { + message.response.complete( + network, + ) + } + } - is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty - listeners.remove(message.key) != null && listeners.isEmpty() - ) { - network = null - unregister() - } + is NetworkMessage.Stop -> + if (listeners.isNotEmpty() && + // was not empty + listeners.remove(message.key) != null && + listeners.isEmpty() + ) { + network = null + unregister() + } - is NetworkMessage.Put -> { - network = message.network - pendingRequests.forEach { it.response.complete(message.network) } - pendingRequests.clear() - listeners.values.forEach { it(network) } - } + is NetworkMessage.Put -> { + network = message.network + pendingRequests.forEach { it.response.complete(message.network) } + pendingRequests.clear() + listeners.values.forEach { it(network) } + } - is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach { - it( - network - ) - } + is NetworkMessage.Update -> + if (network == message.network) { + listeners.values.forEach { + it( + network, + ) + } + } - is NetworkMessage.Lost -> if (network == message.network) { - network = null - listeners.values.forEach { it(null) } + is NetworkMessage.Lost -> + if (network == message.network) { + network = null + listeners.values.forEach { it(null) } + } + } } } - } suspend fun start(key: Any, listener: (Network?) -> Unit) = networkActor.send( NetworkMessage.Start( key, - listener - ) + listener, + ), ) - suspend fun get() = if (fallback) @TargetApi(23) { + suspend fun get(): Network = if (fallback) { + @TargetApi(23) Application.connectivity.activeNetwork ?: error("missing default network") // failed to listen, return current if available - } else NetworkMessage.Get().run { - networkActor.send(this) - response.await() + } else { + NetworkMessage.Get().run { + networkActor.send(this) + response.await() + } } suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key)) @@ -119,15 +139,12 @@ object DefaultNetworkListener { override fun onAvailable(network: Network) = runBlocking { networkActor.send( NetworkMessage.Put( - network - ) + network, + ), ) } - override fun onCapabilitiesChanged( - network: Network, - networkCapabilities: NetworkCapabilities - ) { + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { // it's a good idea to refresh capabilities runBlocking { networkActor.send(NetworkMessage.Update(network)) } } @@ -135,21 +152,22 @@ object DefaultNetworkListener { override fun onLost(network: Network) = runBlocking { networkActor.send( NetworkMessage.Lost( - network - ) + network, + ), ) } } private var fallback = false - private val request = NetworkRequest.Builder().apply { - addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) - if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs - removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) - } - }.build() + private val request = + NetworkRequest.Builder().apply { + addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs + removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL) + } + }.build() private val mainHandler = Handler(Looper.getMainLooper()) /** @@ -164,33 +182,42 @@ object DefaultNetworkListener { */ private fun register() { when (Build.VERSION.SDK_INT) { - in 31..Int.MAX_VALUE -> @TargetApi(31) { - Application.connectivity.registerBestMatchingNetworkCallback( - request, - Callback, - mainHandler - ) - } + in 31..Int.MAX_VALUE -> + @TargetApi(31) + { + Application.connectivity.registerBestMatchingNetworkCallback( + request, + Callback, + mainHandler, + ) + } - in 28 until 31 -> @TargetApi(28) { // we want REQUEST here instead of LISTEN - Application.connectivity.requestNetwork(request, Callback, mainHandler) - } + in 28 until 31 -> + @TargetApi(28) + { // we want REQUEST here instead of LISTEN + Application.connectivity.requestNetwork(request, Callback, mainHandler) + } - in 26 until 28 -> @TargetApi(26) { - Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler) - } + in 26 until 28 -> + @TargetApi(26) + { + Application.connectivity.registerDefaultNetworkCallback(Callback, mainHandler) + } - in 24 until 26 -> @TargetApi(24) { - Application.connectivity.registerDefaultNetworkCallback(Callback) - } + in 24 until 26 -> + @TargetApi(24) + { + Application.connectivity.registerDefaultNetworkCallback(Callback) + } - else -> try { - fallback = false - Application.connectivity.requestNetwork(request, Callback) - } catch (e: RuntimeException) { - fallback = - true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107 - } + else -> + try { + fallback = false + Application.connectivity.requestNetwork(request, Callback) + } catch (e: RuntimeException) { + fallback = + true // known bug on API 23: https://stackoverflow.com/a/33509180/2245107 + } } } @@ -199,4 +226,4 @@ object DefaultNetworkListener { Application.connectivity.unregisterNetworkCallback(Callback) } } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt index 9b5c8743c9..3c02e04138 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/DefaultNetworkMonitor.kt @@ -4,10 +4,6 @@ import android.net.Network import android.os.Build import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.constant.Bugs -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import java.net.NetworkInterface object DefaultNetworkMonitor { @@ -44,9 +40,7 @@ object DefaultNetworkMonitor { checkDefaultInterfaceUpdate(defaultNetwork) } - private fun checkDefaultInterfaceUpdate( - newNetwork: Network? - ) { + private fun checkDefaultInterfaceUpdate(newNetwork: Network?) { val listener = listener ?: return if (newNetwork != null) { val interfaceName = @@ -59,23 +53,10 @@ object DefaultNetworkMonitor { Thread.sleep(100) continue } - if (Bugs.fixAndroidStack) { - GlobalScope.launch(Dispatchers.IO) { - listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) - } - } else { - listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) - } + listener.updateDefaultInterface(interfaceName, interfaceIndex, false, false) } } else { - if (Bugs.fixAndroidStack) { - GlobalScope.launch(Dispatchers.IO) { - listener.updateDefaultInterface("", -1, false, false) - } - } else { - listener.updateDefaultInterface("", -1, false, false) - } + listener.updateDefaultInterface("", -1, false, false) } } - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt index 5db814e091..26f0254382 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LocalResolver.kt @@ -17,12 +17,9 @@ import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine object LocalResolver : LocalDNSTransport { - private const val RCODE_NXDOMAIN = 3 - override fun raw(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - } + override fun raw(): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q @RequiresApi(Build.VERSION_CODES.Q) override fun exchange(ctx: ExchangeContext, message: ByteArray) { @@ -31,52 +28,11 @@ object LocalResolver : LocalDNSTransport { suspendCoroutine { continuation -> val signal = CancellationSignal() ctx.onCancel(signal::cancel) - val callback = object : DnsResolver.Callback { - override fun onAnswer(answer: ByteArray, rcode: Int) { - if (rcode == 0) { - ctx.rawSuccess(answer) - } else { - ctx.errorCode(rcode) - } - continuation.resume(Unit) - } - - override fun onError(error: DnsResolver.DnsException) { - when (val cause = error.cause) { - is ErrnoException -> { - ctx.errnoCode(cause.errno) - continuation.resume(Unit) - return - } - } - continuation.tryResumeWithException(error) - } - } - DnsResolver.getInstance().rawQuery( - defaultNetwork, - message, - DnsResolver.FLAG_NO_RETRY, - Dispatchers.IO.asExecutor(), - signal, - callback - ) - } - } - } - - override fun lookup(ctx: ExchangeContext, network: String, domain: String) { - return runBlocking { - val defaultNetwork = DefaultNetworkMonitor.require() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - suspendCoroutine { continuation -> - val signal = CancellationSignal() - ctx.onCancel(signal::cancel) - val callback = object : DnsResolver.Callback> { - @Suppress("ThrowableNotThrown") - override fun onAnswer(answer: Collection, rcode: Int) { + val callback = + object : DnsResolver.Callback { + override fun onAnswer(answer: ByteArray, rcode: Int) { if (rcode == 0) { - ctx.success((answer as Collection).mapNotNull { it?.hostAddress } - .joinToString("\n")) + ctx.rawSuccess(answer) } else { ctx.errorCode(rcode) } @@ -94,11 +50,57 @@ object LocalResolver : LocalDNSTransport { continuation.tryResumeWithException(error) } } - val type = when { - network.endsWith("4") -> DnsResolver.TYPE_A - network.endsWith("6") -> DnsResolver.TYPE_AAAA - else -> null - } + DnsResolver.getInstance().rawQuery( + defaultNetwork, + message, + DnsResolver.FLAG_NO_RETRY, + Dispatchers.IO.asExecutor(), + signal, + callback, + ) + } + } + } + + override fun lookup(ctx: ExchangeContext, network: String, domain: String) { + return runBlocking { + val defaultNetwork = DefaultNetworkMonitor.require() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + suspendCoroutine { continuation -> + val signal = CancellationSignal() + ctx.onCancel(signal::cancel) + val callback = + object : DnsResolver.Callback> { + @Suppress("ThrowableNotThrown") + override fun onAnswer(answer: Collection, rcode: Int) { + if (rcode == 0) { + ctx.success( + (answer as Collection).mapNotNull { it?.hostAddress } + .joinToString("\n"), + ) + } else { + ctx.errorCode(rcode) + } + continuation.resume(Unit) + } + + override fun onError(error: DnsResolver.DnsException) { + when (val cause = error.cause) { + is ErrnoException -> { + ctx.errnoCode(cause.errno) + continuation.resume(Unit) + return + } + } + continuation.tryResumeWithException(error) + } + } + val type = + when { + network.endsWith("4") -> DnsResolver.TYPE_A + network.endsWith("6") -> DnsResolver.TYPE_AAAA + else -> null + } if (type != null) { DnsResolver.getInstance().query( defaultNetwork, @@ -107,7 +109,7 @@ object LocalResolver : LocalDNSTransport { DnsResolver.FLAG_NO_RETRY, Dispatchers.IO.asExecutor(), signal, - callback + callback, ) } else { DnsResolver.getInstance().query( @@ -116,19 +118,20 @@ object LocalResolver : LocalDNSTransport { DnsResolver.FLAG_NO_RETRY, Dispatchers.IO.asExecutor(), signal, - callback + callback, ) } } } else { - val answer = try { - defaultNetwork.getAllByName(domain) - } catch (e: UnknownHostException) { - ctx.errorCode(RCODE_NXDOMAIN) - return@runBlocking - } + val answer = + try { + defaultNetwork.getAllByName(domain) + } catch (e: UnknownHostException) { + ctx.errorCode(RCODE_NXDOMAIN) + return@runBlocking + } ctx.success(answer.mapNotNull { it.hostAddress }.joinToString("\n")) } } } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java new file mode 100644 index 0000000000..f8b2ed3f74 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/LogEntry.java @@ -0,0 +1,67 @@ +package io.nekohasekai.sfa.bg; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class LogEntry implements Parcelable { + public static final int LEVEL_DEBUG = 0; + public static final int LEVEL_INFO = 1; + public static final int LEVEL_WARN = 2; + public static final int LEVEL_ERROR = 3; + + public final int level; + public final long timestamp; + @NonNull public final String source; + @NonNull public final String message; + @Nullable public final String stackTrace; + + public LogEntry( + int level, + long timestamp, + @NonNull String source, + @NonNull String message, + @Nullable String stackTrace) { + this.level = level; + this.timestamp = timestamp; + this.source = source; + this.message = message; + this.stackTrace = stackTrace; + } + + protected LogEntry(Parcel in) { + level = in.readInt(); + timestamp = in.readLong(); + source = in.readString(); + message = in.readString(); + stackTrace = in.readString(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeInt(level); + dest.writeLong(timestamp); + dest.writeString(source); + dest.writeString(message); + dest.writeString(stackTrace); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = + new Creator<>() { + @Override + public LogEntry createFromParcel(Parcel in) { + return new LogEntry(in); + } + + @Override + public LogEntry[] newArray(int size) { + return new LogEntry[size]; + } + }; +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java new file mode 100644 index 0000000000..c4471937fd --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PackageEntry.java @@ -0,0 +1,40 @@ +package io.nekohasekai.sfa.bg; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.NonNull; + +public class PackageEntry implements Parcelable { + @NonNull public final String packageName; + + public PackageEntry(@NonNull String packageName) { + this.packageName = packageName; + } + + protected PackageEntry(Parcel in) { + packageName = in.readString(); + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeString(packageName); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator CREATOR = + new Creator<>() { + @Override + public PackageEntry createFromParcel(Parcel in) { + return new PackageEntry(in); + } + + @Override + public PackageEntry[] newArray(int size) { + return new PackageEntry[size]; + } + }; +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java new file mode 100644 index 0000000000..9840067555 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ParceledListSlice.java @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2011 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.nekohasekai.sfa.bg; + +import android.os.Binder; +import android.os.IBinder; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.RemoteException; +import java.util.ArrayList; +import java.util.List; + +public class ParceledListSlice implements Parcelable { + private static final int MAX_IPC_SIZE = 64 * 1024; + + private final List mList; + + public ParceledListSlice(List list) { + mList = list; + } + + private ParceledListSlice(Parcel in, ClassLoader loader) { + final int n = in.readInt(); + mList = new ArrayList<>(n); + if (n <= 0) { + return; + } + + int i = 0; + while (i < n) { + if (in.readInt() == 0) { + break; + } + @SuppressWarnings("unchecked") + T item = (T) in.readParcelable(loader); + mList.add(item); + i++; + } + if (i >= n) { + return; + } + final IBinder retriever = in.readStrongBinder(); + while (i < n) { + Parcel data = Parcel.obtain(); + Parcel reply = Parcel.obtain(); + data.writeInt(i); + try { + retriever.transact(IBinder.FIRST_CALL_TRANSACTION, data, reply, 0); + } catch (RemoteException e) { + reply.recycle(); + data.recycle(); + return; + } + while (i < n && reply.readInt() != 0) { + @SuppressWarnings("unchecked") + T item = (T) reply.readParcelable(loader); + mList.add(item); + i++; + } + reply.recycle(); + data.recycle(); + } + } + + public List getList() { + return mList; + } + + @Override + public int describeContents() { + int contents = 0; + for (int i = 0; i < mList.size(); i++) { + contents |= mList.get(i).describeContents(); + } + return contents; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + final int n = mList.size(); + dest.writeInt(n); + if (n <= 0) { + return; + } + int i = 0; + while (i < n && dest.dataSize() < MAX_IPC_SIZE) { + dest.writeInt(1); + dest.writeParcelable(mList.get(i), flags); + i++; + } + if (i < n) { + dest.writeInt(0); + final int start = i; + Binder retriever = + new Binder() { + @Override + protected boolean onTransact(int code, Parcel data, Parcel reply, int flags) + throws RemoteException { + if (code != FIRST_CALL_TRANSACTION) { + return super.onTransact(code, data, reply, flags); + } + int i = data.readInt(); + if (i < start || i > n) { + return false; + } + while (i < n && reply.dataSize() < MAX_IPC_SIZE) { + reply.writeInt(1); + reply.writeParcelable(mList.get(i), flags); + i++; + } + if (i < n) { + reply.writeInt(0); + } + return true; + } + }; + dest.writeStrongBinder(retriever); + } + } + + public static final Parcelable.ClassLoaderCreator CREATOR = + new Parcelable.ClassLoaderCreator() { + @Override + public ParceledListSlice createFromParcel(Parcel in) { + return new ParceledListSlice(in, null); + } + + @Override + public ParceledListSlice createFromParcel(Parcel in, ClassLoader loader) { + return new ParceledListSlice(in, loader); + } + + @Override + public ParceledListSlice[] newArray(int size) { + return new ParceledListSlice[size]; + } + }; +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt index 068a529c9b..fa7cea5ba4 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/PlatformInterfaceWrapper.kt @@ -1,13 +1,13 @@ package io.nekohasekai.sfa.bg import android.annotation.SuppressLint -import android.content.pm.PackageManager import android.net.NetworkCapabilities import android.os.Build import android.os.Process import android.system.OsConstants import android.util.Log import androidx.annotation.RequiresApi +import io.nekohasekai.libbox.ConnectionOwner import io.nekohasekai.libbox.InterfaceUpdateListener import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.LocalDNSTransport @@ -27,10 +27,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi import io.nekohasekai.libbox.NetworkInterface as LibboxNetworkInterface interface PlatformInterfaceWrapper : PlatformInterface { - - override fun usePlatformAutoDetectInterfaceControl(): Boolean { - return true - } + override fun usePlatformAutoDetectInterfaceControl(): Boolean = true override fun autoDetectInterfaceControl(fd: Int) { } @@ -39,9 +36,7 @@ interface PlatformInterfaceWrapper : PlatformInterface { error("invalid argument") } - override fun useProcFS(): Boolean { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q - } + override fun useProcFS(): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.Q @RequiresApi(Build.VERSION_CODES.Q) override fun findConnectionOwner( @@ -49,16 +44,22 @@ interface PlatformInterfaceWrapper : PlatformInterface { sourceAddress: String, sourcePort: Int, destinationAddress: String, - destinationPort: Int - ): Int { + destinationPort: Int, + ): ConnectionOwner { try { - val uid = Application.connectivity.getConnectionOwnerUid( - ipProtocol, - InetSocketAddress(sourceAddress, sourcePort), - InetSocketAddress(destinationAddress, destinationPort) - ) + val uid = + Application.connectivity.getConnectionOwnerUid( + ipProtocol, + InetSocketAddress(sourceAddress, sourcePort), + InetSocketAddress(destinationAddress, destinationPort), + ) if (uid == Process.INVALID_UID) error("android: connection owner not found") - return uid + val packages = Application.packageManager.getPackagesForUid(uid) + val owner = ConnectionOwner() + owner.userId = uid + owner.userName = packages?.firstOrNull() ?: "" + owner.androidPackageName = packages?.firstOrNull() ?: "" + return owner } catch (e: Exception) { Log.e("PlatformInterface", "getConnectionOwnerUid", e) e.printStackTrace(System.err) @@ -66,29 +67,6 @@ interface PlatformInterfaceWrapper : PlatformInterface { } } - override fun packageNameByUid(uid: Int): String { - val packages = Application.packageManager.getPackagesForUid(uid) - if (packages.isNullOrEmpty()) error("android: package not found") - return packages[0] - } - - @Suppress("DEPRECATION") - override fun uidByPackageName(packageName: String): Int { - return try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Application.packageManager.getPackageUid( - packageName, PackageManager.PackageInfoFlags.of(0) - ) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Application.packageManager.getPackageUid(packageName, 0) - } else { - Application.packageManager.getApplicationInfo(packageName, 0).uid - } - } catch (e: PackageManager.NameNotFoundException) { - error("android: package not found") - } - } - override fun startDefaultInterfaceMonitor(listener: InterfaceUpdateListener) { DefaultNetworkMonitor.setListener(listener) } @@ -111,23 +89,28 @@ interface PlatformInterfaceWrapper : PlatformInterface { networkInterfaces.find { it.name == boxInterface.name } ?: continue boxInterface.dnsServer = StringArray(linkProperties.dnsServers.mapNotNull { it.hostAddress }.iterator()) - boxInterface.type = when { - networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Libbox.InterfaceTypeWIFI - networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Libbox.InterfaceTypeCellular - networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Libbox.InterfaceTypeEthernet - else -> Libbox.InterfaceTypeOther - } + boxInterface.type = + when { + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Libbox.InterfaceTypeWIFI + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Libbox.InterfaceTypeCellular + networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Libbox.InterfaceTypeEthernet + else -> Libbox.InterfaceTypeOther + } boxInterface.index = networkInterface.index runCatching { boxInterface.mtu = networkInterface.mtu }.onFailure { Log.e( - "PlatformInterface", "failed to get mtu for interface ${boxInterface.name}", it + "PlatformInterface", + "failed to get mtu for interface ${boxInterface.name}", + it, ) } boxInterface.addresses = - StringArray(networkInterface.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() } - .iterator()) + StringArray( + networkInterface.interfaceAddresses.mapTo(mutableListOf()) { it.toPrefix() } + .iterator(), + ) var dumpFlags = 0 if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { dumpFlags = OsConstants.IFF_UP or OsConstants.IFF_RUNNING @@ -149,19 +132,16 @@ interface PlatformInterfaceWrapper : PlatformInterface { return InterfaceArray(interfaces.iterator()) } - override fun underNetworkExtension(): Boolean { - return false - } + override fun underNetworkExtension(): Boolean = false - override fun includeAllNetworks(): Boolean { - return false - } + override fun includeAllNetworks(): Boolean = false override fun clearDNSCache() { } override fun readWIFIState(): WIFIState? { - @Suppress("DEPRECATION") val wifiInfo = + @Suppress("DEPRECATION") + val wifiInfo = Application.wifiManager.connectionInfo ?: return null var ssid = wifiInfo.ssid if (ssid == "") { @@ -173,67 +153,52 @@ interface PlatformInterfaceWrapper : PlatformInterface { return WIFIState(ssid, wifiInfo.bssid) } - override fun localDNSTransport(): LocalDNSTransport? { - return LocalResolver - } + override fun localDNSTransport(): LocalDNSTransport? = LocalResolver @OptIn(ExperimentalEncodingApi::class) override fun systemCertificates(): StringIterator { val certificates = mutableListOf() val keyStore = KeyStore.getInstance("AndroidCAStore") if (keyStore != null) { - keyStore.load(null, null); + keyStore.load(null, null) val aliases = keyStore.aliases() while (aliases.hasMoreElements()) { val cert = keyStore.getCertificate(aliases.nextElement()) certificates.add( - "-----BEGIN CERTIFICATE-----\n" + Base64.encode(cert.encoded) + "\n-----END CERTIFICATE-----" + "-----BEGIN CERTIFICATE-----\n" + Base64.encode(cert.encoded) + "\n-----END CERTIFICATE-----", ) } } return StringArray(certificates.iterator()) } - private class InterfaceArray(private val iterator: Iterator) : - NetworkInterfaceIterator { - - override fun hasNext(): Boolean { - return iterator.hasNext() - } - - override fun next(): LibboxNetworkInterface { - return iterator.next() - } + private class InterfaceArray(private val iterator: Iterator) : NetworkInterfaceIterator { + override fun hasNext(): Boolean = iterator.hasNext() + override fun next(): LibboxNetworkInterface = iterator.next() } - private class StringArray(private val iterator: Iterator) : StringIterator { - + class StringArray(private val iterator: Iterator) : StringIterator { override fun len(): Int { // not used by core return 0 } - override fun hasNext(): Boolean { - return iterator.hasNext() - } + override fun hasNext(): Boolean = iterator.hasNext() - override fun next(): String { - return iterator.next() - } + override fun next(): String = iterator.next() } - private fun InterfaceAddress.toPrefix(): String { - return if (address is Inet6Address) { - "${Inet6Address.getByAddress(address.address).hostAddress}/${networkPrefixLength}" - } else { - "${address.hostAddress}/${networkPrefixLength}" - } + private fun InterfaceAddress.toPrefix(): String = if (address is Inet6Address) { + "${Inet6Address.getByAddress(address.address).hostAddress}/$networkPrefixLength" + } else { + "${address.hostAddress}/$networkPrefixLength" } private val NetworkInterface.flags: Int - @SuppressLint("SoonBlockedPrivateApi") get() { + @SuppressLint("SoonBlockedPrivateApi") + get() { val getFlagsMethod = NetworkInterface::class.java.getDeclaredMethod("getFlags") return getFlagsMethod.invoke(this) as Int } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt index 6087d49e33..74c04120ec 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ProxyService.kt @@ -4,19 +4,16 @@ import android.app.Service import android.content.Intent import io.nekohasekai.libbox.Notification -class ProxyService : Service(), PlatformInterfaceWrapper { - +class ProxyService : + Service(), + PlatformInterfaceWrapper { private val service = BoxService(this, this) - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = - service.onStartCommand() + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = service.onStartCommand() override fun onBind(intent: Intent) = service.onBind() + override fun onDestroy() = service.onDestroy() - override fun writeLog(message: String) = service.writeLog(message) - - override fun sendNotification(notification: Notification) = - service.sendNotification(notification) - -} \ No newline at end of file + override fun sendNotification(notification: Notification) = service.sendNotification(notification) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt new file mode 100644 index 0000000000..ab33003685 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootClient.kt @@ -0,0 +1,106 @@ +package io.nekohasekai.sfa.bg + +import android.content.ComponentName +import android.content.Intent +import android.content.ServiceConnection +import android.content.pm.PackageInfo +import android.os.IBinder +import android.os.RemoteException +import com.topjohnwu.superuser.Shell +import com.topjohnwu.superuser.ipc.RootService +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.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume + +object RootClient { + init { + Shell.enableVerboseLogging = BuildConfig.DEBUG + Shell.setDefaultBuilder( + Shell.Builder.create() + .setFlags(Shell.FLAG_MOUNT_MASTER) + .setTimeout(10), + ) + } + + private val _rootAvailable = MutableStateFlow(null) + val rootAvailable: StateFlow = _rootAvailable + + private val _serviceConnected = MutableStateFlow(false) + val serviceConnected: StateFlow = _serviceConnected + + private var service: IRootService? = null + private var connection: ServiceConnection? = null + private val connectionMutex = Mutex() + + suspend fun checkRootAvailable(): Boolean { + Shell.getCachedShell()?.close() + return suspendCancellableCoroutine { continuation -> + Shell.getShell { shell -> + val available = shell.isRoot + _rootAvailable.value = available + continuation.resume(available) + } + } + } + + suspend fun bindService(): IRootService = connectionMutex.withLock { + service?.let { return it } + + return withContext(Dispatchers.Main) { + suspendCancellableCoroutine { continuation -> + val conn = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { + val svc = IRootService.Stub.asInterface(binder) + service = svc + connection = this + _serviceConnected.value = true + continuation.resume(svc) + } + + override fun onServiceDisconnected(name: ComponentName?) { + service = null + connection = null + _serviceConnected.value = false + } + } + + val intent = Intent(Application.application, RootServer::class.java) + RootService.bind(intent, conn) + + continuation.invokeOnCancellation { + RootService.unbind(conn) + } + } + } + } + + fun unbindService() { + connection?.let { + RootService.unbind(it) + connection = null + service = null + _serviceConnected.value = false + } + } + + suspend fun getInstalledPackages(flags: Int): List { + val userId = android.os.Process.myUserHandle().hashCode() + val svc = bindService() + return try { + val slice = svc.getInstalledPackages(flags, userId) + + @Suppress("UNCHECKED_CAST") + val list = slice.list as List + list + } catch (e: RemoteException) { + throw e.rethrowFromSystemServer() + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt new file mode 100644 index 0000000000..352d159637 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/RootServer.kt @@ -0,0 +1,37 @@ +package io.nekohasekai.sfa.bg + +import android.content.Intent +import android.content.pm.PackageInfo +import android.os.IBinder +import android.os.ParcelFileDescriptor +import com.topjohnwu.superuser.ipc.RootService +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.vendor.PrivilegedServiceUtils +import java.io.IOException + +class RootServer : RootService() { + + private val binder = object : IRootService.Stub() { + override fun destroy() { + stopSelf() + } + + override fun getInstalledPackages(flags: Int, userId: Int): ParceledListSlice { + val allPackages = PrivilegedServiceUtils.getInstalledPackages(flags, userId) + return ParceledListSlice(allPackages) + } + + override fun installPackage(apk: ParcelFileDescriptor?, size: Long, userId: Int) { + if (apk == null) throw IOException("APK file descriptor is null") + PrivilegedServiceUtils.installPackage(apk, size, userId) + } + + override fun exportDebugInfo(outputPath: String?): String = DebugInfoExporter.export( + this@RootServer, + outputPath!!, + BuildConfig.APPLICATION_ID, + ) + } + + override fun onBind(intent: Intent): IBinder = binder +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt index 0f8a605554..c430114441 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceBinder.kt @@ -43,9 +43,7 @@ class ServiceBinder(private val status: MutableLiveData) : IService.Stub } } - override fun getStatus(): Int { - return (status.value ?: Status.Stopped).ordinal - } + override fun getStatus(): Int = (status.value ?: Status.Stopped).ordinal override fun registerCallback(callback: IServiceCallback) { callbacks.register(callback) @@ -58,4 +56,4 @@ class ServiceBinder(private val status: MutableLiveData) : IService.Stub fun close() { callbacks.kill() } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt index c9d31f988e..d6c76c914d 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceConnection.kt @@ -18,12 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -class ServiceConnection( - private val context: Context, - callback: Callback, - private val register: Boolean = true, -) : ServiceConnection { - +class ServiceConnection(private val context: Context, callback: Callback, private val register: Boolean = true) : ServiceConnection { companion object { private const val TAG = "ServiceConnection" } @@ -34,11 +29,12 @@ class ServiceConnection( val status get() = service?.status?.let { Status.values()[it] } ?: Status.Stopped fun connect() { - val intent = runBlocking { - withContext(Dispatchers.IO) { - Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) + val intent = + runBlocking { + withContext(Dispatchers.IO) { + Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) + } } - } context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) Log.d(TAG, "request connect") } @@ -56,11 +52,12 @@ class ServiceConnection( context.unbindService(this) } catch (_: IllegalArgumentException) { } - val intent = runBlocking { - withContext(Dispatchers.IO) { - Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) + val intent = + runBlocking { + withContext(Dispatchers.IO) { + Intent(context, Settings.serviceClass()).setAction(Action.SERVICE) + } } - } context.bindService(intent, this, AppCompatActivity.BIND_AUTO_CREATE) Log.d(TAG, "request reconnect") } @@ -93,7 +90,9 @@ class ServiceConnection( interface Callback { fun onServiceStatusChanged(status: Status) - fun onServiceAlert(type: Alert, message: String?) {} + + fun onServiceAlert(type: Alert, message: String?) { + } } class ServiceCallback(private val callback: Callback) : IServiceCallback.Stub() { @@ -105,4 +104,4 @@ class ServiceConnection( callback.onServiceAlert(Alert.values()[type], message) } } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt index 7a8b0f065d..bd1d7fb4ce 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/ServiceNotification.kt @@ -17,19 +17,19 @@ import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.StatusMessage import io.nekohasekai.sfa.Application import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.MainActivity import io.nekohasekai.sfa.constant.Action import io.nekohasekai.sfa.constant.Status import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.ui.MainActivity import io.nekohasekai.sfa.utils.CommandClient import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.withContext -class ServiceNotification( - private val status: MutableLiveData, private val service: Service -) : BroadcastReceiver(), CommandClient.Handler { +class ServiceNotification(private val status: MutableLiveData, private val service: Service) : + BroadcastReceiver(), + CommandClient.Handler { companion object { private const val notificationId = 1 private const val notificationChannel = "service" @@ -60,21 +60,23 @@ class ServiceNotification( 0, Intent( service, - MainActivity::class.java + MainActivity::class.java, ).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), - flags - ) + flags, + ), ) .setPriority(NotificationCompat.PRIORITY_LOW).apply { addAction( NotificationCompat.Action.Builder( - 0, service.getText(R.string.stop), PendingIntent.getBroadcast( + 0, + service.getText(R.string.stop), + PendingIntent.getBroadcast( service, 0, Intent(Action.SERVICE_CLOSE).setPackage(service.packageName), - flags - ) - ).build() + flags, + ), + ).build(), ) } } @@ -83,14 +85,17 @@ class ServiceNotification( if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { Application.notification.createNotificationChannel( NotificationChannel( - notificationChannel, "Service Notifications", NotificationManager.IMPORTANCE_LOW - ) + notificationChannel, + "Service Notifications", + NotificationManager.IMPORTANCE_LOW, + ), ) } service.startForeground( - notificationId, notificationBuilder + notificationId, + notificationBuilder .setContentTitle(lastProfileName.takeIf { it.isNotBlank() } ?: "sing-box") - .setContentText(service.getString(contentTextId)).build() + .setContentText(service.getString(contentTextId)).build(), ) } @@ -104,10 +109,13 @@ class ServiceNotification( } private fun registerReceiver() { - service.registerReceiver(this, IntentFilter().apply { - addAction(Intent.ACTION_SCREEN_ON) - addAction(Intent.ACTION_SCREEN_OFF) - }) + service.registerReceiver( + this, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }, + ) receiverRegistered = true } @@ -116,7 +124,7 @@ class ServiceNotification( Libbox.formatBytes(status.uplink) + "/s ↑\t" + Libbox.formatBytes(status.downlink) + "/s ↓" Application.notificationManager.notify( notificationId, - notificationBuilder.setContentText(content).build() + notificationBuilder.setContentText(content).build(), ) } @@ -140,4 +148,4 @@ class ServiceNotification( receiverRegistered = false } } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt index 30fa9302d7..3b96002cd7 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/TileService.kt @@ -8,17 +8,19 @@ import androidx.annotation.RequiresApi import io.nekohasekai.sfa.constant.Status @RequiresApi(24) -class TileService : TileService(), ServiceConnection.Callback { - +class TileService : + TileService(), + ServiceConnection.Callback { private val connection = ServiceConnection(this, this) override fun onServiceStatusChanged(status: Status) { qsTile?.apply { - state = when (status) { - Status.Started -> Tile.STATE_ACTIVE - Status.Stopped -> Tile.STATE_INACTIVE - else -> Tile.STATE_UNAVAILABLE - } + state = + when (status) { + Status.Started -> Tile.STATE_ACTIVE + Status.Stopped -> Tile.STATE_INACTIVE + else -> Tile.STATE_UNAVAILABLE + } updateTile() } } @@ -51,5 +53,4 @@ class TileService : TileService(), ServiceConnection.Callback { else -> {} } } - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt index 9dee852fdb..8f4969423f 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/UpdateProfileWork.kt @@ -19,7 +19,6 @@ import java.util.Date import java.util.concurrent.TimeUnit class UpdateProfileWork { - companion object { private const val WORK_NAME = "UpdateProfile" private const val TAG = "UpdateProfileWork" @@ -33,8 +32,9 @@ class UpdateProfileWork { } private suspend fun reconfigureUpdater0() { - val remoteProfiles = ProfileManager.list() - .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } + val remoteProfiles = + ProfileManager.list() + .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } if (remoteProfiles.isEmpty()) { WorkManager.getInstance(Application.application).cancelUniqueWork(WORK_NAME) return @@ -54,19 +54,17 @@ class UpdateProfileWork { if (minInitDelay > 0) setInitialDelay(minInitDelay, TimeUnit.SECONDS) setBackoffCriteria(BackoffPolicy.LINEAR, 15, TimeUnit.MINUTES) } - .build() + .build(), ) } - } - class UpdateTask( - appContext: Context, params: WorkerParameters - ) : CoroutineWorker(appContext, params) { + class UpdateTask(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { var selectedProfileUpdated = false - val remoteProfiles = ProfileManager.list() - .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } + val remoteProfiles = + ProfileManager.list() + .filter { it.typed.type == TypedProfile.Type.Remote && it.typed.autoUpdate } if (remoteProfiles.isEmpty()) return Result.success() var success = true val selectedProfile = Settings.selectedProfile @@ -104,8 +102,5 @@ class UpdateProfileWork { Result.retry() } } - } - - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt index 4973c701c8..d9581844dd 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/bg/VPNService.kt @@ -6,6 +6,7 @@ import android.net.ProxyInfo import android.net.VpnService import android.os.Build import android.os.IBinder +import android.util.Log import io.nekohasekai.libbox.Notification import io.nekohasekai.libbox.TunOptions import io.nekohasekai.sfa.database.Settings @@ -15,16 +16,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -class VPNService : VpnService(), PlatformInterfaceWrapper { - +class VPNService : + VpnService(), + PlatformInterfaceWrapper { companion object { private const val TAG = "VPNService" } private val service = BoxService(this, this) - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = - service.onStartCommand() + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int) = service.onStartCommand() override fun onBind(intent: Intent): IBinder { val binder = super.onBind(intent) @@ -56,9 +57,10 @@ class VPNService : VpnService(), PlatformInterfaceWrapper { override fun openTun(options: TunOptions): Int { if (prepare(this) != null) error("android: missing vpn permission") - val builder = Builder() - .setSession("sing-box") - .setMtu(options.mtu) + val builder = + Builder() + .setSession("sing-box") + .setMtu(options.mtu) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { builder.setMetered(false) @@ -125,42 +127,28 @@ class VPNService : VpnService(), PlatformInterfaceWrapper { } } - if (Settings.perAppProxyEnabled) { - val appList = Settings.perAppProxyList - if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { - appList.forEach { - try { - builder.addAllowedApplication(it) - } catch (_: NameNotFoundException) { - } - } - builder.addAllowedApplication(packageName) - } else { - appList.forEach { - try { - builder.addDisallowedApplication(it) - } catch (_: NameNotFoundException) { - } - } - } - } else { - val includePackage = options.includePackage - if (includePackage.hasNext()) { - while (includePackage.hasNext()) { - try { - builder.addAllowedApplication(includePackage.next()) - } catch (_: NameNotFoundException) { - } + val includePackage = options.includePackage + if (includePackage.hasNext()) { + while (includePackage.hasNext()) { + try { + val nextPackage = includePackage.next() + builder.addAllowedApplication(nextPackage) + Log.d("VPNService", "addAllowedApplication: $nextPackage") + } catch (e: NameNotFoundException) { + Log.e("VPNService", "addAllowedApplication failed", e) } } + } - val excludePackage = options.excludePackage - if (excludePackage.hasNext()) { - while (excludePackage.hasNext()) { - try { - builder.addDisallowedApplication(excludePackage.next()) - } catch (_: NameNotFoundException) { - } + val excludePackage = options.excludePackage + if (excludePackage.hasNext()) { + while (excludePackage.hasNext()) { + try { + val nextPackage = excludePackage.next() + builder.addDisallowedApplication(nextPackage) + Log.d("VPNService", "addDisallowedApplication: $nextPackage") + } catch (e: NameNotFoundException) { + Log.e("VPNService", "addDisallowedApplication failed", e) } } } @@ -169,13 +157,15 @@ class VPNService : VpnService(), PlatformInterfaceWrapper { if (options.isHTTPProxyEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { systemProxyAvailable = true systemProxyEnabled = Settings.systemProxyEnabled - if (systemProxyEnabled) builder.setHttpProxy( - ProxyInfo.buildDirectProxy( - options.httpProxyServer, - options.httpProxyServerPort, - options.httpProxyBypassDomain.toList() + if (systemProxyEnabled) { + builder.setHttpProxy( + ProxyInfo.buildDirectProxy( + options.httpProxyServer, + options.httpProxyServerPort, + options.httpProxyBypassDomain.toList(), + ), ) - ) + } } else { systemProxyAvailable = false systemProxyEnabled = false @@ -187,9 +177,5 @@ class VPNService : VpnService(), PlatformInterfaceWrapper { return pfd.fd } - override fun writeLog(message: String) = service.writeLog(message) - - override fun sendNotification(notification: Notification) = - service.sendNotification(notification) - -} \ No newline at end of file + override fun sendNotification(notification: Notification) = service.sendNotification(notification) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt new file mode 100644 index 0000000000..867de45fb2 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/LineChart.kt @@ -0,0 +1,131 @@ +package io.nekohasekai.sfa.compose + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.dp +import kotlin.math.max + +@Composable +fun LineChart( + data: List, + modifier: Modifier = Modifier, + lineColor: Color = MaterialTheme.colorScheme.primary, + gridColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + animate: Boolean = true, +) { + val animationProgress = remember { Animatable(if (animate) 0f else 1f) } + + LaunchedEffect(data) { + if (animate) { + animationProgress.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 300), + ) + } + } + + Canvas( + modifier = + modifier + .fillMaxWidth() + .height(80.dp), + ) { + val width = size.width + val height = size.height + val maxValue = max(data.maxOrNull() ?: 1f, 1f) * 1.2f // Add 20% padding + val pointCount = data.size + + // Draw horizontal grid lines + val gridLineCount = 3 + for (i in 0..gridLineCount) { + val y = height * i / gridLineCount + drawLine( + color = gridColor, + start = Offset(0f, y), + end = Offset(width, y), + strokeWidth = 1.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f), + ) + } + + if (pointCount > 1) { + val path = Path() + val spacing = width / (pointCount - 1).toFloat() + + // Calculate points + val points = + data.mapIndexed { index, value -> + val x = index * spacing + val normalizedValue = (value / maxValue).coerceIn(0f, 1f) + val y = height * (1 - normalizedValue) + Offset(x, y) + } + + // Build the path + path.moveTo(points[0].x, points[0].y) + for (i in 1 until points.size) { + val progress = if (animate) animationProgress.value else 1f + val pointIndex = (i * progress).toInt().coerceAtMost(points.size - 1) + + if (i <= pointIndex) { + val prev = points[i - 1] + val current = points[i] + + // Simple line connection + path.lineTo(current.x, current.y) + } + } + + // Draw the line + drawPath( + path = path, + color = lineColor, + style = + Stroke( + width = 2.dp.toPx(), + cap = StrokeCap.Round, + join = StrokeJoin.Round, + ), + ) + + // Draw gradient fill under the line + val fillPath = Path() + fillPath.addPath(path) + + // Complete the fill area + if (points.isNotEmpty()) { + val progressIndex = ((points.size - 1) * animationProgress.value).toInt() + val lastPoint = + if (progressIndex >= 0 && progressIndex < points.size) { + points[progressIndex] + } else { + points.last() + } + + fillPath.lineTo(lastPoint.x, height) + fillPath.lineTo(0f, height) + fillPath.lineTo(points[0].x, points[0].y) + + drawPath( + path = fillPath, + color = lineColor.copy(alpha = 0.1f), + ) + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt new file mode 100644 index 0000000000..574422ba29 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/MainActivity.kt @@ -0,0 +1,1244 @@ +package io.nekohasekai.sfa.compose + +import android.Manifest +import android.annotation.SuppressLint +import android.content.ContentResolver +import android.content.Intent +import android.net.Uri +import android.net.VpnService +import android.os.Build +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.UnfoldLess +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import dev.jeziellago.compose.markdowntext.MarkdownText +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.ServiceConnection +import io.nekohasekai.sfa.bg.ServiceNotification +import io.nekohasekai.sfa.compat.WindowSizeClassCompat +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.ServiceStatusBar +import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog +import io.nekohasekai.sfa.compose.component.UptimeText +import io.nekohasekai.sfa.compose.model.Connection +import io.nekohasekai.sfa.compose.navigation.NewProfileArgs +import io.nekohasekai.sfa.compose.navigation.ProfileRoutes +import io.nekohasekai.sfa.compose.navigation.SFANavHost +import io.nekohasekai.sfa.compose.navigation.Screen +import io.nekohasekai.sfa.compose.navigation.bottomNavigationScreens +import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler +import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsScreen +import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage +import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel +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.theme.SFATheme +import io.nekohasekai.sfa.compose.topbar.LocalTopBarController +import io.nekohasekai.sfa.compose.topbar.TopBarController +import io.nekohasekai.sfa.compose.topbar.TopBarEntry +import io.nekohasekai.sfa.constant.Alert +import io.nekohasekai.sfa.constant.ServiceMode +import io.nekohasekai.sfa.constant.Status +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.vendor.Vendor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class MainActivity : + AppCompatActivity(), + ServiceConnection.Callback { + private val connection = ServiceConnection(this, this) + private lateinit var dashboardViewModel: DashboardViewModel + private var currentServiceStatus by mutableStateOf(Status.Stopped) + private var currentAlert by mutableStateOf?>(null) + private var showLocationPermissionDialog by mutableStateOf(false) + private var showBackgroundLocationDialog by mutableStateOf(false) + private var showImportProfileDialog by mutableStateOf(false) + private var pendingImportProfile by mutableStateOf?>(null) + private var showImportLocalProfileDialog by mutableStateOf(false) + private var pendingImportLocalProfileName by mutableStateOf(null) + private var pendingImportLocalProfileUri by mutableStateOf(null) + private var newProfileArgs by mutableStateOf(NewProfileArgs()) + private var parseImportLocalProfileJob: Job? = null + private var pendingIntentErrorMessage by mutableStateOf(null) + + private val notificationPermissionLauncher = + registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (Settings.dynamicNotification && !isGranted) { + onServiceAlert(Alert.RequestNotificationPermission, null) + } else { + startService0() + } + } + + private val locationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) { + if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + requestBackgroundLocationPermission() + } else { + startService() + } + } + } + + private val backgroundLocationPermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (it) { + startService() + } + } + + private val prepareLauncher = + registerForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode == RESULT_OK) { + startService0() + } else { + onServiceAlert(Alert.RequestVPNPermission, null) + } + } + private val pendingNavigationRoute = mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + connection.reconnect() + + UpdateState.loadFromCache() + if (Settings.checkUpdateEnabled) { + lifecycleScope.launch(Dispatchers.IO) { + try { + val updateInfo = Vendor.checkUpdateAsync() + UpdateState.setUpdate(updateInfo) + } catch (_: Exception) { + UpdateState.setUpdate(null) + } + } + } + + handleIntent(intent) + + setContent { + SFATheme { + SFAApp() + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent?) { + if (intent == null) { + return + } + if (intent.categories?.contains("de.robv.android.xposed.category.MODULE_SETTINGS") == true) { + pendingNavigationRoute.value = "settings/privilege" + } + val uri = intent.data ?: return + if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { + try { + val profile = Libbox.parseRemoteProfileImportLink(uri.toString()) + pendingImportProfile = Triple(profile.name, profile.host, profile.url) + showImportProfileDialog = true + } catch (e: Exception) { + pendingIntentErrorMessage = e.message ?: "Failed to parse profile link" + } + return + } + + if (intent.action == Intent.ACTION_VIEW && + (uri.scheme == ContentResolver.SCHEME_CONTENT || uri.scheme == ContentResolver.SCHEME_FILE) + ) { + parseImportLocalProfileJob?.cancel() + parseImportLocalProfileJob = + lifecycleScope.launch(Dispatchers.IO) { + val importHandler = ProfileImportHandler(this@MainActivity) + when (val result = importHandler.parseUri(uri)) { + is ProfileImportHandler.UriParseResult.Success -> { + withContext(Dispatchers.Main) { + pendingImportLocalProfileName = result.name + pendingImportLocalProfileUri = uri + showImportLocalProfileDialog = true + } + } + + is ProfileImportHandler.UriParseResult.Error -> { + withContext(Dispatchers.Main) { + pendingIntentErrorMessage = result.message + } + } + } + } + } + } + + @SuppressLint("NewApi") + fun startService() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !ServiceNotification.checkPermission()) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + return + } + startService0() + } + + private fun startService0() { + lifecycleScope.launch(Dispatchers.IO) { + if (Settings.rebuildServiceMode()) { + connection.reconnect() + } + if (Settings.serviceMode == ServiceMode.VPN) { + if (prepare()) { + return@launch + } + } + val intent = Intent(Application.application, Settings.serviceClass()) + withContext(Dispatchers.Main) { + ContextCompat.startForegroundService(this@MainActivity, intent) + } + Settings.startedByUser = true + } + } + + private suspend fun prepare() = withContext(Dispatchers.Main) { + try { + val intent = VpnService.prepare(this@MainActivity) + if (intent != null) { + prepareLauncher.launch(intent) + true + } else { + false + } + } catch (e: Exception) { + onServiceAlert(Alert.RequestVPNPermission, e.message) + true + } + } + + @OptIn(ExperimentalMaterial3Api::class) + @Composable + fun SFAApp() { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + val currentRoute = currentDestination?.route + val scope = rememberCoroutineScope() + val importHandler = remember { ProfileImportHandler(this@MainActivity) } + + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val useNavigationRail = + windowSizeClass.isWidthAtLeastBreakpointCompat(WindowSizeClassCompat.WIDTH_DP_MEDIUM_LOWER_BOUND) + + // Snackbar state + val snackbarHostState = remember { SnackbarHostState() } + + // Groups Sheet state + var showGroupsSheet by remember { mutableStateOf(false) } + + // Connections Sheet state + 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) { + errorMessage = pendingIntentError + showErrorDialog = true + pendingIntentErrorMessage = null + } + } + val topBarState = remember { mutableStateOf(emptyList()) } + val topBarController = remember { TopBarController(topBarState) } + val topBarOverride = topBarState.value.lastOrNull()?.content + val openNewProfile: (NewProfileArgs) -> Unit = { args -> + newProfileArgs = args + navController.navigate(ProfileRoutes.NewProfile) { + launchSingleTop = true + } + } + + // Handle service alerts + currentAlert?.let { (alertType, message) -> + ServiceAlertDialog( + alertType = alertType, + message = message, + onDismiss = { currentAlert = null }, + ) + } + + // Handle UiEvent.ShowError dialog + if (showErrorDialog) { + SelectableMessageDialog( + title = stringResource(R.string.error_title), + message = errorMessage, + onDismiss = { showErrorDialog = false }, + ) + } + + // Handle location permission dialogs + if (showLocationPermissionDialog) { + LocationPermissionDialog(onConfirm = { + showLocationPermissionDialog = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + }, onDismiss = { showLocationPermissionDialog = false }) + } + + if (showBackgroundLocationDialog) { + BackgroundLocationPermissionDialog(onConfirm = { + showBackgroundLocationDialog = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + backgroundLocationPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } + }, onDismiss = { showBackgroundLocationDialog = false }) + } + + // Handle import remote profile dialog + if (showImportProfileDialog && pendingImportProfile != null) { + val (name, host, url) = pendingImportProfile!! + AlertDialog( + onDismissRequest = { + showImportProfileDialog = false + pendingImportProfile = null + }, + title = { Text(stringResource(R.string.import_remote_profile)) }, + text = { Text(stringResource(R.string.import_remote_profile_message, name, host)) }, + confirmButton = { + TextButton(onClick = { + openNewProfile( + NewProfileArgs( + importName = name, + importUrl = url, + ), + ) + showImportProfileDialog = false + pendingImportProfile = null + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { + showImportProfileDialog = false + pendingImportProfile = null + }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + if (showImportLocalProfileDialog && pendingImportLocalProfileUri != null && pendingImportLocalProfileName != null) { + val importName = pendingImportLocalProfileName!! + val importUri = pendingImportLocalProfileUri!! + AlertDialog( + onDismissRequest = { + showImportLocalProfileDialog = false + pendingImportLocalProfileName = null + pendingImportLocalProfileUri = null + }, + title = { Text(stringResource(R.string.import_profile_confirm_title)) }, + text = { Text(stringResource(R.string.import_profile_confirm_message, importName)) }, + confirmButton = { + TextButton(onClick = { + showImportLocalProfileDialog = false + pendingImportLocalProfileName = null + pendingImportLocalProfileUri = null + scope.launch { + when (val result = importHandler.importFromUri(importUri)) { + is ProfileImportHandler.ImportResult.Success -> { + navController.navigate(ProfileRoutes.editProfile(result.profile.id)) { + launchSingleTop = true + } + } + is ProfileImportHandler.ImportResult.Error -> { + errorMessage = result.message + showErrorDialog = true + } + } + } + }) { + Text(stringResource(R.string.import_action)) + } + }, + dismissButton = { + TextButton(onClick = { + showImportLocalProfileDialog = false + pendingImportLocalProfileName = null + pendingImportLocalProfileUri = null + }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + // Handle update check prompt dialog (shown only once on first launch) + var showUpdateCheckPrompt by remember { mutableStateOf(!Settings.updateCheckPrompted) } + if (showUpdateCheckPrompt) { + AlertDialog( + onDismissRequest = { + Settings.updateCheckPrompted = true + showUpdateCheckPrompt = false + }, + title = { Text(stringResource(R.string.check_update)) }, + text = { + MarkdownText( + markdown = stringResource( + if (BuildConfig.FLAVOR == "play") { + R.string.check_update_prompt_play + } else { + R.string.check_update_prompt_github + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + }, + confirmButton = { + TextButton(onClick = { + Settings.updateCheckPrompted = true + Settings.checkUpdateEnabled = true + showUpdateCheckPrompt = false + scope.launch(Dispatchers.IO) { + try { + val result = Vendor.checkUpdateAsync() + UpdateState.setUpdate(result) + } catch (_: Exception) { + UpdateState.setUpdate(null) + } + } + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { + Settings.updateCheckPrompted = true + showUpdateCheckPrompt = false + }) { + Text(stringResource(R.string.no_thanks)) + } + }, + ) + } + + // Handle update available dialog + val updateInfo by UpdateState.updateInfo + val shouldShowUpdateDialog = updateInfo != null && + updateInfo!!.versionCode > Settings.lastShownUpdateVersion + var showUpdateDialog by remember { mutableStateOf(true) } + + // Download dialog state + var showDownloadDialog by remember { mutableStateOf(false) } + var downloadJob by remember { mutableStateOf(null) } + var downloadError by remember { mutableStateOf(null) } + + if (showUpdateDialog && shouldShowUpdateDialog) { + UpdateAvailableDialog( + updateInfo = updateInfo!!, + onDismiss = { + Settings.lastShownUpdateVersion = updateInfo!!.versionCode + showUpdateDialog = false + }, + onUpdate = { + showDownloadDialog = true + downloadError = null + downloadJob = scope.launch { + try { + withContext(Dispatchers.IO) { + Vendor.downloadAndInstall( + this@MainActivity, + updateInfo!!.downloadUrl, + ) + } + showDownloadDialog = false + } catch (e: Exception) { + downloadError = e.message + } + } + }, + ) + } + + // Download progress dialog + if (showDownloadDialog) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.update)) }, + text = { + Column { + if (downloadError != null) { + Text( + downloadError!!, + color = MaterialTheme.colorScheme.error, + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.downloading)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + downloadJob?.cancel() + downloadJob = null + showDownloadDialog = false + downloadError = null + }, + ) { + Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) + } + }, + ) + } + + // Initialize the dashboard view model and store reference + val dashboardViewModel: DashboardViewModel = viewModel() + if (!::dashboardViewModel.isInitialized) { + this.dashboardViewModel = dashboardViewModel + } + val dashboardUiState by dashboardViewModel.uiState.collectAsState() + + val isSettingsSubScreen = currentRoute?.startsWith("settings/") == true + val isConnectionsDetail = currentRoute?.startsWith("connections/detail") == true + val isProfileRoute = currentRoute?.startsWith("profile/") == true + val currentRootRoute = + when { + isSettingsSubScreen -> Screen.Settings.route + currentRoute?.startsWith(Screen.Connections.route) == true -> Screen.Connections.route + currentRoute?.startsWith(Screen.Log.route) == true -> Screen.Log.route + isProfileRoute -> Screen.Dashboard.route + else -> currentRoute + } + val isConnectionsRoute = currentRootRoute == Screen.Connections.route + val isGroupsRoute = currentRootRoute == Screen.Groups.route + val isLogRoute = currentRootRoute == Screen.Log.route + + val isSubScreen = isSettingsSubScreen || isConnectionsDetail || isProfileRoute + // Get LogViewModel instance if we're on the Log screen + val logViewModel: LogViewModel? = + if (isLogRoute) { + viewModel() + } else { + null + } + + val groupsViewModel: GroupsViewModel? = + if (isGroupsRoute) { + viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return GroupsViewModel(dashboardViewModel.commandClient) as T + } + }, + ) + } else { + null + } + + val connectionsViewModel: ConnectionsViewModel? = + if (isConnectionsRoute) { + viewModel() + } else { + null + } + + val showGroupsInNav = dashboardUiState.hasGroups + val showConnectionsInNav = + currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting + + val railScreens = + buildList { + add(Screen.Dashboard) + if (showGroupsInNav) { + add(Screen.Groups) + } + if (showConnectionsInNav) { + add(Screen.Connections) + } + add(Screen.Log) + add(Screen.Settings) + } + + val allowedRoutes = + buildSet { + add(Screen.Dashboard.route) + add(Screen.Log.route) + add(Screen.Settings.route) + if (useNavigationRail && showGroupsInNav) { + add(Screen.Groups.route) + } + if (useNavigationRail && showConnectionsInNav) { + add(Screen.Connections.route) + } + } + + val pendingRoute = pendingNavigationRoute.value + LaunchedEffect(pendingRoute) { + if (pendingRoute != null) { + navController.navigate(pendingRoute) { + launchSingleTop = true + } + pendingNavigationRoute.value = null + } + } + + LaunchedEffect(allowedRoutes, currentRootRoute, useNavigationRail) { + if (currentRootRoute != null && !allowedRoutes.contains(currentRootRoute)) { + navController.navigate(Screen.Dashboard.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + } + + // Collect all UI events from GlobalEventBus + LaunchedEffect(Unit) { + GlobalEventBus.events.collect { event -> + when (event) { + is UiEvent.ErrorMessage -> { + errorMessage = event.message + showErrorDialog = true + } + + is UiEvent.OpenUrl -> { + this@MainActivity.launchCustomTab(event.url) + } + + is UiEvent.RequestStartService -> { + startService() + } + + is UiEvent.RequestReconnectService -> { + connection.reconnect() + } + + is UiEvent.EditProfile -> { + navController.navigate(ProfileRoutes.editProfile(event.profileId)) { + launchSingleTop = true + } + } + + 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() + } + } + } + } + } + } + } + } + + val topBarContent: @Composable () -> Unit = { + topBarOverride?.invoke() + } + + val scaffoldContent: @Composable (PaddingValues) -> Unit = { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + // Service Status Bar (shown when service is running or stopping) + val serviceRunning = + currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting + val showStatusBar = serviceRunning || currentServiceStatus == Status.Stopping + val showStartFab = !serviceRunning && dashboardUiState.selectedProfileId != -1L + + SFANavHost( + navController = navController, + serviceStatus = currentServiceStatus, + showStartFab = showStartFab, + showStatusBar = showStatusBar, + newProfileArgs = newProfileArgs, + onClearNewProfileArgs = { newProfileArgs = NewProfileArgs() }, + onOpenNewProfile = openNewProfile, + dashboardViewModel = dashboardViewModel, + logViewModel = logViewModel, + groupsViewModel = groupsViewModel, + connectionsViewModel = connectionsViewModel, + 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), + ) + } + + val showPadFab = useNavigationRail && !isSubScreen && (showStartFab || showStatusBar) + if (useNavigationRail) { + androidx.compose.animation.AnimatedVisibility( + visible = showPadFab, + enter = scaleIn(), + exit = scaleOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(20.dp), + ) { + val isRunning = + currentServiceStatus == Status.Started || currentServiceStatus == Status.Starting + val isStopping = currentServiceStatus == Status.Stopping + if (currentServiceStatus == Status.Stopped) { + FloatingActionButton( + onClick = { startService() }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.action_start), + ) + } + } else { + ExtendedFloatingActionButton( + onClick = { + if (isRunning || isStopping) { + dashboardViewModel.toggleService() + } else { + startService() + } + }, + icon = { + Icon( + imageVector = + if (isRunning || isStopping) { + Icons.Default.Stop + } else { + Icons.Default.PlayArrow + }, + contentDescription = + if (isRunning || isStopping) { + stringResource(R.string.stop) + } else { + stringResource(R.string.action_start) + }, + ) + }, + text = { + when { + isRunning && dashboardUiState.serviceStartTime != null -> { + UptimeText(startTime = dashboardUiState.serviceStartTime!!) + } + currentServiceStatus == Status.Started -> { + Text( + text = stringResource(R.string.status_started), + style = MaterialTheme.typography.labelLarge, + ) + } + currentServiceStatus == Status.Starting -> { + Text( + text = stringResource(R.string.status_starting), + style = MaterialTheme.typography.labelLarge, + ) + } + currentServiceStatus == Status.Stopping -> { + Text( + text = stringResource(R.string.status_stopping), + style = MaterialTheme.typography.labelLarge, + ) + } + else -> { + Text( + text = stringResource(R.string.action_start), + style = MaterialTheme.typography.labelLarge, + ) + } + } + }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.height(64.dp), + ) + } + } + } else { + // Start FAB (shown when service is stopped and a profile is selected) + androidx.compose.animation.AnimatedVisibility( + visible = currentServiceStatus == Status.Stopped && + dashboardUiState.selectedProfileId != -1L && + !isSubScreen, + enter = scaleIn(), + exit = scaleOut(), + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + FloatingActionButton( + onClick = { startService() }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = stringResource(R.string.action_start), + ) + } + } + } + } + } + + CompositionLocalProvider(LocalTopBarController provides topBarController) { + if (useNavigationRail) { + Row(modifier = Modifier.fillMaxSize()) { + Surface(tonalElevation = 1.dp) { + NavigationRail( + modifier = Modifier.fillMaxHeight(), + ) { + val hasUpdate by UpdateState.hasUpdate + railScreens.forEach { screen -> + val selected = currentRootRoute == screen.route + + NavigationRailItem( + icon = { + if (screen == Screen.Settings && hasUpdate) { + BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { + Icon(screen.icon, contentDescription = null) + } + } else { + Icon(screen.icon, contentDescription = null) + } + }, + label = { Text(stringResource(screen.titleRes)) }, + selected = selected, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + ) + } + } + } + + Scaffold( + modifier = Modifier.weight(1f), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = topBarContent, + ) { paddingValues -> + scaffoldContent(paddingValues) + } + } + } else { + Scaffold( + modifier = Modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + topBar = topBarContent, + bottomBar = { + if (!isSubScreen) { + val hasUpdate by UpdateState.hasUpdate + NavigationBar { + bottomNavigationScreens.forEach { screen -> + NavigationBarItem( + icon = { + if (screen == Screen.Settings && hasUpdate) { + BadgedBox(badge = { Badge(containerColor = MaterialTheme.colorScheme.primary) }) { + Icon(screen.icon, contentDescription = null) + } + } else { + Icon(screen.icon, contentDescription = null) + } + }, + label = { Text(stringResource(screen.titleRes)) }, + selected = + currentDestination?.hierarchy?.any { + it.route == screen.route + } == true, + onClick = { + navController.navigate(screen.route) { + // Pop up to the start destination of the graph to + // avoid building up a large stack of destinations + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + // Avoid multiple copies of the same destination + launchSingleTop = true + // Restore state when reselecting a previously selected item + restoreState = true + } + }, + ) + } + } + } + }, + ) { paddingValues -> + scaffoldContent(paddingValues) + } + } + } + + // Groups ModalBottomSheet + if (showGroupsSheet && !useNavigationRail) { + val groupsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val groupsViewModel: GroupsViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return GroupsViewModel(dashboardViewModel.commandClient) as T + } + }, + ) + val groupsUiState by groupsViewModel.uiState.collectAsState() + val allCollapsed = groupsUiState.expandedGroups.isEmpty() + + ModalBottomSheet( + onDismissRequest = { showGroupsSheet = false }, + sheetState = groupsSheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + ) { + // Groups content + GroupsCard( + serviceStatus = currentServiceStatus, + commandClient = dashboardViewModel.commandClient, + viewModel = groupsViewModel, + listHeaderContent = { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.title_groups), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (groupsUiState.groups.isNotEmpty()) { + IconButton(onClick = { groupsViewModel.toggleAllGroups() }) { + Icon( + imageVector = if (allCollapsed) { + Icons.Default.UnfoldMore + } else { + Icons.Default.UnfoldLess + }, + contentDescription = if (allCollapsed) { + stringResource(R.string.expand_all) + } else { + stringResource(R.string.collapse_all) + }, + ) + } + } + } + }, + asSheet = true, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + // Connections ModalBottomSheet + if (showConnectionsSheet && !useNavigationRail) { + val connectionsSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val connectionsViewModel: ConnectionsViewModel = viewModel() + val connectionsUiState by connectionsViewModel.uiState.collectAsState() + var selectedConnectionId by remember { mutableStateOf(null) } + val selectedConnection = connectionsUiState.allConnections.find { it.id == selectedConnectionId } + var cachedConnection by remember { mutableStateOf(null) } + if (selectedConnection != null) { + cachedConnection = selectedConnection + } else if (selectedConnectionId != null && cachedConnection?.isActive == true) { + cachedConnection = cachedConnection?.copy(closedAt = System.currentTimeMillis()) + } + val displayConnection = if (selectedConnectionId != null) cachedConnection else null + + LaunchedEffect(Unit) { + connectionsViewModel.setVisible(true) + } + + DisposableEffect(Unit) { + onDispose { + connectionsViewModel.setVisible(false) + } + } + + ModalBottomSheet( + onDismissRequest = { + showConnectionsSheet = false + selectedConnectionId = null + }, + sheetState = connectionsSheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + ) { + if (displayConnection != null) { + ConnectionDetailsScreen( + connection = displayConnection, + onBack = { selectedConnectionId = null }, + onClose = { + selectedConnectionId?.let { connectionsViewModel.closeConnection(it) } + }, + asSheet = true, + ) + } else { + ConnectionsPage( + serviceStatus = currentServiceStatus, + viewModel = connectionsViewModel, + asSheet = true, + showTitle = true, + onConnectionClick = { selectedConnectionId = it }, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } + + override fun onServiceStatusChanged(status: Status) { + currentServiceStatus = status + // Update service status in ViewModels + if (::dashboardViewModel.isInitialized) { + dashboardViewModel.updateServiceStatus(status) + } + } + + fun reconnect() { + connection.reconnect() + } + + override fun onServiceAlert(type: Alert, message: String?) { + when (type) { + Alert.RequestLocationPermission -> { + return requestLocationPermission() + } + + else -> { + currentAlert = Pair(type, message) + } + } + } + + private fun requestLocationPermission() { + if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { + requestFineLocationPermission() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + requestBackgroundLocationPermission() + } + } + + private fun requestFineLocationPermission() { + // Show location permission dialog in Compose UI + showLocationPermissionDialog = true + } + + private fun requestBackgroundLocationPermission() { + // Show background location permission dialog in Compose UI + showBackgroundLocationDialog = true + } + + override fun onDestroy() { + connection.disconnect() + super.onDestroy() + } + + @Composable + private fun ServiceAlertDialog(alertType: Alert, message: String?, onDismiss: () -> Unit) { + val title = + when (alertType) { + Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_title) + Alert.StartCommandServer -> stringResource(R.string.error_start_command_server) + Alert.CreateService -> stringResource(R.string.error_create_service) + Alert.StartService -> stringResource(R.string.error_start_service) + else -> null + } + + val dialogMessage = + when (alertType) { + Alert.RequestVPNPermission -> stringResource(R.string.error_missing_vpn_permission) + Alert.RequestNotificationPermission -> stringResource(R.string.notification_permission_required_description) + Alert.EmptyConfiguration -> stringResource(R.string.error_empty_configuration) + else -> message + } + + AlertDialog( + onDismissRequest = onDismiss, + title = title?.let { { Text(text = it) } }, + text = dialogMessage?.let { { Text(text = it) } }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ok)) + } + }, + ) + } + + @Composable + private fun LocationPermissionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.location_permission_title)) }, + text = { Text(stringResource(R.string.location_permission_description)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.no_thanks)) + } + }, + ) + } + + @Composable + private fun BackgroundLocationPermissionDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.location_permission_title)) }, + text = { Text(stringResource(R.string.location_permission_background_description)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.no_thanks)) + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt new file mode 100644 index 0000000000..b78d059888 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/BaseViewModel.kt @@ -0,0 +1,74 @@ +package io.nekohasekai.sfa.compose.base + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +abstract class BaseViewModel : ViewModel() { + private val _uiState: MutableStateFlow by lazy { MutableStateFlow(createInitialState()) } + val uiState: StateFlow = _uiState.asStateFlow() + + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() + + abstract fun createInitialState(): State + + protected val currentState: State + get() = _uiState.value + + protected fun updateState(reducer: State.() -> State) { + _uiState.value = _uiState.value.reducer() + } + + /** + * Send an event that will be handled locally by the screen. + * For global events, use sendGlobalEvent() instead. + */ + protected fun sendEvent(event: Event) { + viewModelScope.launch { + _events.emit(event) + } + } + + /** + * Send a global UI event that will be handled by ComposeActivity. + * This is a convenience method for sending UiEvents to the global bus. + */ + fun sendGlobalEvent(event: UiEvent) { + viewModelScope.launch { + GlobalEventBus.emit(event) + } + } + + /** + * Send an error event to be displayed as a dialog. + * This is a convenience method for the common error handling case. + */ + protected fun sendErrorMessage(message: String) { + sendGlobalEvent(UiEvent.ErrorMessage(message)) + } + + protected fun launch(onError: ((Throwable) -> Unit)? = null, block: suspend CoroutineScope.() -> Unit) { + val errorHandler = + CoroutineExceptionHandler { _, throwable -> + onError?.invoke(throwable) ?: sendError(throwable) + } + + viewModelScope.launch(errorHandler, block = block) + } + + /** + * Convenience method to handle exceptions with a custom fallback message + */ + protected fun sendError(throwable: Throwable) { + sendErrorMessage(throwable.message ?: "An unknown error occurred") + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt new file mode 100644 index 0000000000..b31d900b65 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/GlobalEventBus.kt @@ -0,0 +1,33 @@ +package io.nekohasekai.sfa.compose.base + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Global event bus that aggregates events from all ViewModels. + * This allows ComposeActivity to handle all events in a centralized manner. + */ +object GlobalEventBus { + private val _events = + MutableSharedFlow( + replay = 0, + extraBufferCapacity = 10, + ) + + val events: SharedFlow = _events.asSharedFlow() + + /** + * Emit an event to the global event bus. + * This should be called by ViewModels to send events that need global handling. + */ + suspend fun emit(event: UiEvent) { + _events.emit(event) + } + + /** + * Try to emit an event without suspending. + * Returns true if the event was emitted successfully. + */ + fun tryEmit(event: UiEvent): Boolean = _events.tryEmit(event) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt new file mode 100644 index 0000000000..c5742a67f7 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/SelectableMessageDialog.kt @@ -0,0 +1,56 @@ +package io.nekohasekai.sfa.compose.base + +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R + +@Composable +fun SelectableMessageDialog(title: String, message: String, onDismiss: () -> Unit) { + val clipboard = LocalClipboardManager.current + val context = LocalContext.current + val scrollState = rememberScrollState() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Box( + modifier = Modifier + .heightIn(max = 320.dp) + .verticalScroll(scrollState), + ) { + SelectionContainer { + Text(message) + } + } + }, + dismissButton = { + TextButton( + onClick = { + clipboard.setText(AnnotatedString(message)) + Toast.makeText(context, context.getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() + }, + ) { + Text(stringResource(R.string.per_app_proxy_action_copy)) + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.ok)) + } + }, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt new file mode 100644 index 0000000000..6b7467a325 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiEvent.kt @@ -0,0 +1,43 @@ +package io.nekohasekai.sfa.compose.base + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +/** + * Base sealed class for all UI events in the application. + * These are one-time events that should trigger UI actions. + */ +sealed class UiEvent { + data class ErrorMessage(val message: String) : UiEvent() + + data class OpenUrl(val url: String) : UiEvent() + + data class EditProfile(val profileId: Long) : UiEvent() + + object RequestStartService : UiEvent() + + object RequestReconnectService : UiEvent() + + object RestartToTakeEffect : UiEvent() +} + +/** + * Interface for screen-specific events that don't need global handling + */ +interface ScreenEvent + +interface EventHandler { + val events: SharedFlow + + suspend fun sendEvent(event: T) +} + +class UiEventHandler : EventHandler { + private val _events = MutableSharedFlow() + override val events: SharedFlow = _events.asSharedFlow() + + override suspend fun sendEvent(event: T) { + _events.emit(event) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt new file mode 100644 index 0000000000..ed9c3570af --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/base/UiState.kt @@ -0,0 +1,11 @@ +package io.nekohasekai.sfa.compose.base + +sealed class UiState { + object Loading : UiState() + + data class Success(val data: T) : UiState() + + data class Error(val exception: Throwable, val message: String? = null) : UiState() +} + +data class BaseUiState(val isLoading: Boolean = false, val data: T? = null, val error: String? = null) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt new file mode 100644 index 0000000000..225942c155 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/ServiceStatusBar.kt @@ -0,0 +1,207 @@ +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.Stop +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.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.Status +import kotlinx.coroutines.delay + +@Composable +fun ServiceStatusBar( + visible: Boolean, + serviceStatus: Status, + startTime: Long?, + groupsCount: Int, + hasGroups: Boolean, + onGroupsClick: () -> Unit, + connectionsCount: Int, + onConnectionsClick: () -> Unit, + onStopClick: () -> 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, + ) { + // Status text + StatusItem( + text = when (serviceStatus) { + Status.Starting -> stringResource(R.string.status_starting) + Status.Started -> stringResource(R.string.status_started) + Status.Stopping -> stringResource(R.string.status_stopping) + else -> "" + }, + modifier = Modifier.weight(1f), + ) + + // Connections button + 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, + ) + } + + // Groups button (only show if hasGroups) + 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, + ) + } + } + + // Stop button + Row( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable(onClick = onStopClick) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (startTime != null) { + UptimeText(startTime = startTime) + Spacer(modifier = Modifier.width(4.dp)) + } + Icon( + imageVector = Icons.Default.Stop, + contentDescription = stringResource(R.string.stop), + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + } +} + +@Composable +private fun StatusItem(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = modifier, + ) +} + +@Composable +fun UptimeText(startTime: Long, modifier: Modifier = Modifier) { + var currentTime by remember { mutableLongStateOf(System.currentTimeMillis()) } + + LaunchedEffect(startTime) { + while (true) { + delay(1000) + currentTime = System.currentTimeMillis() + } + } + + val elapsedSeconds = ((currentTime - startTime) / 1000).coerceAtLeast(0) + val hours = elapsedSeconds / 3600 + val minutes = (elapsedSeconds % 3600) / 60 + val seconds = elapsedSeconds % 60 + + val formattedTime = + if (hours > 0) { + String.format("%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format("%d:%02d", minutes, seconds) + } + + Text( + text = formattedTime, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = modifier, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt new file mode 100644 index 0000000000..ff18d53970 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/UpdateDialog.kt @@ -0,0 +1,86 @@ +package io.nekohasekai.sfa.compose.component + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.jeziellago.compose.markdowntext.MarkdownText +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.update.UpdateInfo +import org.kodein.emoji.Emoji +import org.kodein.emoji.EmojiTemplateCatalog +import org.kodein.emoji.all + +@Composable +fun UpdateAvailableDialog(updateInfo: UpdateInfo, onDismiss: () -> Unit, onUpdate: () -> Unit) { + val context = LocalContext.current + val emojiCatalog = remember { EmojiTemplateCatalog(Emoji.all()) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.check_update)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + ) { + Text( + text = stringResource(R.string.new_version_available, updateInfo.versionName), + style = MaterialTheme.typography.bodyMedium, + ) + + if (!updateInfo.releaseNotes.isNullOrBlank()) { + val processedNotes = remember(updateInfo.releaseNotes) { + emojiCatalog.replaceShortcodes(updateInfo.releaseNotes) + } + Spacer(modifier = Modifier.height(12.dp)) + MarkdownText( + markdown = processedNotes, + style = MaterialTheme.typography.bodySmall.copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) + } + } + }, + confirmButton = { + TextButton( + onClick = { + onDismiss() + onUpdate() + }, + ) { + Text(stringResource(R.string.update)) + } + }, + dismissButton = { + Row { + TextButton(onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(updateInfo.releaseUrl)) + context.startActivity(intent) + onDismiss() + }) { + Text(stringResource(R.string.view_release)) + } + Spacer(modifier = Modifier.width(8.dp)) + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + } + }, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt new file mode 100644 index 0000000000..535b87c400 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRCodeDialog.kt @@ -0,0 +1,65 @@ +package io.nekohasekai.sfa.compose.component.qr + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + +@Composable +fun QRCodeDialog(bitmap: Bitmap, onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight(), + shape = RoundedCornerShape(16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + shape = RoundedCornerShape(0.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(io.nekohasekai.sfa.R.string.content_description_qr_code), + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt new file mode 100644 index 0000000000..3a36f52a53 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSBitmapState.kt @@ -0,0 +1,108 @@ +package io.nekohasekai.sfa.compose.component.qr + +import android.graphics.Bitmap +import io.nekohasekai.sfa.compose.util.QRCodeGenerator +import io.nekohasekai.sfa.qrs.QRSEncoder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield + +data class QRSGenerationState( + val currentBitmap: Bitmap? = null, + val currentFrameIndex: Int = 0, + val generatedCount: Int = 0, + val totalFrames: Int = 0, + val isGenerating: Boolean = true, +) + +class QRSBitmapGenerator( + private val scope: CoroutineScope, + private val frames: List, + private val foregroundColor: Int, + private val backgroundColor: Int, + bufferSize: Int = 30, +) { + private val _state = MutableStateFlow(QRSGenerationState(totalFrames = frames.size)) + val state: StateFlow = _state + + private val actualBufferSize = bufferSize.coerceAtMost(frames.size) + private val bitmapBuffer = arrayOfNulls(actualBufferSize) + private var generationJob: Job? = null + + @Volatile + private var currentFrameIndex = 0 + private var generatedUpTo = -1 + + fun start() { + if (frames.isEmpty()) { + _state.value = _state.value.copy(isGenerating = false) + return + } + + generationJob = scope.launch { + val firstBitmap = withContext(Dispatchers.Default) { + QRCodeGenerator.generate( + content = frames[0].content, + foregroundColor = foregroundColor, + backgroundColor = backgroundColor, + ) + } + bitmapBuffer[0] = firstBitmap + generatedUpTo = 0 + _state.value = _state.value.copy( + currentBitmap = firstBitmap, + generatedCount = 1, + isGenerating = frames.size > 1, + ) + + for (i in 1 until frames.size) { + yield() + val bitmap = withContext(Dispatchers.Default) { + QRCodeGenerator.generate( + content = frames[i].content, + foregroundColor = foregroundColor, + backgroundColor = backgroundColor, + ) + } + + val bufferIndex = i % actualBufferSize + val currentDisplayBufferIndex = currentFrameIndex % actualBufferSize + if (bufferIndex != currentDisplayBufferIndex) { + bitmapBuffer[bufferIndex]?.recycle() + } + bitmapBuffer[bufferIndex] = bitmap + generatedUpTo = i + + _state.value = _state.value.copy( + generatedCount = i + 1, + isGenerating = i < frames.size - 1, + ) + } + } + } + + fun advanceFrame() { + if (generatedUpTo < 0) return + + val nextIndex = (currentFrameIndex + 1) % frames.size + if (nextIndex <= generatedUpTo || generatedUpTo == frames.size - 1) { + currentFrameIndex = nextIndex + } + + val bufferIndex = currentFrameIndex % actualBufferSize + val bitmap = bitmapBuffer[bufferIndex] + _state.value = _state.value.copy( + currentBitmap = bitmap, + currentFrameIndex = currentFrameIndex, + ) + } + + fun cancel() { + generationJob?.cancel() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt new file mode 100644 index 0000000000..e1300403d1 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRSDialog.kt @@ -0,0 +1,295 @@ +package io.nekohasekai.sfa.compose.component.qr + +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.aspectRatio +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.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +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.mutableIntStateOf +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.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compat.WindowSizeClassCompat +import io.nekohasekai.sfa.compat.isWidthAtLeastBreakpointCompat +import io.nekohasekai.sfa.qrs.QRSConstants +import io.nekohasekai.sfa.qrs.QRSEncoder +import kotlinx.coroutines.delay + +@Composable +fun QRSDialog(profileData: ByteArray, profileName: String, onDismiss: () -> Unit) { + val context = LocalContext.current + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isTablet = windowSizeClass.isWidthAtLeastBreakpointCompat(WindowSizeClassCompat.WIDTH_DP_MEDIUM_LOWER_BOUND) + val coroutineScope = rememberCoroutineScope() + var fps by remember { mutableIntStateOf(QRSConstants.DEFAULT_FPS) } + var sliceSize by remember { mutableIntStateOf(QRSConstants.DEFAULT_SLICE_SIZE) } + + val encoder = remember(sliceSize) { QRSEncoder(sliceSize) } + val dataWithMeta = remember(profileData, profileName) { + QRSEncoder.appendFileHeaderMeta( + data = profileData, + filename = "$profileName.bpf", + contentType = "application/octet-stream", + ) + } + val requiredFrames = remember(dataWithMeta, sliceSize) { + QRSConstants.calculateRequiredFrames(dataWithMeta.size, sliceSize) + } + val frames = remember(dataWithMeta, sliceSize, requiredFrames) { + encoder.encode(dataWithMeta, QRSConstants.OFFICIAL_URL_PREFIX) + .take(requiredFrames) + .toList() + } + + val frameInterval = remember(fps) { 1000L / fps } + + val generator = remember(frames) { + QRSBitmapGenerator( + scope = coroutineScope, + frames = frames, + foregroundColor = Color.BLACK, + backgroundColor = Color.WHITE, + bufferSize = QRSConstants.BITMAP_BUFFER_SIZE, + ) + } + + val generationState by generator.state.collectAsState() + + LaunchedEffect(generator) { + generator.start() + } + + DisposableEffect(generator) { + onDispose { + generator.cancel() + } + } + + LaunchedEffect(frameInterval, generationState.generatedCount) { + if (generationState.generatedCount > 0) { + while (true) { + delay(frameInterval) + generator.advanceFrame() + } + } + } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = + if (isTablet) { + Modifier + .fillMaxWidth(0.85f) + .sizeIn(maxWidth = 960.dp) + .wrapContentHeight() + } else { + Modifier + .fillMaxWidth(0.9f) + .wrapContentHeight() + }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + val qrSurface: @Composable () -> Unit = { + Surface( + modifier = Modifier + .sizeIn(maxWidth = if (isTablet) 420.dp else 360.dp, maxHeight = if (isTablet) 420.dp else 360.dp) + .fillMaxWidth() + .aspectRatio(1f), + shape = RoundedCornerShape(0.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(androidx.compose.ui.graphics.Color.White), + contentAlignment = Alignment.Center, + ) { + generationState.currentBitmap?.let { bitmap -> + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = stringResource(R.string.content_description_qr_code), + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } + } + } + } + + val controlsContent: @Composable () -> Unit = { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.qrs_fps), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "$fps Hz", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Slider( + value = fps.toFloat(), + onValueChange = { fps = it.toInt() }, + valueRange = QRSConstants.MIN_FPS.toFloat()..QRSConstants.MAX_FPS.toFloat(), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.qrs_slice_size), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = "$sliceSize", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Slider( + value = sliceSize.toFloat(), + onValueChange = { sliceSize = it.toInt() }, + valueRange = QRSConstants.MIN_SLICE_SIZE.toFloat()..QRSConstants.MAX_SLICE_SIZE.toFloat(), + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/qifi-dev/qrs")) + context.startActivity(intent) + }, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.qrs_what_is_qrs)) + } + + Button( + onClick = onDismiss, + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.close)) + } + } + } + + if (isTablet) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.Top, + ) { + Column( + modifier = Modifier.weight(1f), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + qrSurface() + } + + Column( + modifier = Modifier.weight(1f), + ) { + controlsContent() + } + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + qrSurface() + + Spacer(modifier = Modifier.height(16.dp)) + + controlsContent() + } + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt new file mode 100644 index 0000000000..ea0677e9b6 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/component/qr/QRScanSheet.kt @@ -0,0 +1,362 @@ +package io.nekohasekai.sfa.compose.component.qr + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.view.PreviewView +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +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.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea +import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult +import io.nekohasekai.sfa.compose.screen.qrscan.QRScanViewModel +import kotlin.math.max + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun QRScanSheet(onDismiss: () -> Unit, onScanResult: (QRScanResult) -> Unit, viewModel: QRScanViewModel = viewModel()) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val uiState by viewModel.uiState.collectAsState() + + var hasPermission by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED, + ) + } + + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + hasPermission = true + } else { + onDismiss() + } + } + + LaunchedEffect(Unit) { + viewModel.resetQRSState() + if (!hasPermission) { + permissionLauncher.launch(Manifest.permission.CAMERA) + } + } + + LaunchedEffect(uiState.result) { + uiState.result?.let { result -> + viewModel.clearResult() + onScanResult(result) + } + } + + var showMenu by remember { mutableStateOf(false) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.9f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.profile_add_scan_qr_code), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + ) + + Box { + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_options), + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { + Text( + (if (uiState.useFrontCamera) "✓ " else " ") + + stringResource(R.string.profile_add_scan_use_front_camera), + ) + }, + onClick = { + viewModel.toggleFrontCamera(lifecycleOwner) + showMenu = false + }, + ) + DropdownMenuItem( + text = { + Text( + (if (uiState.torchEnabled) "✓ " else " ") + + stringResource(R.string.profile_add_scan_enable_torch), + ) + }, + onClick = { + viewModel.toggleTorch() + showMenu = false + }, + ) + if (uiState.vendorAnalyzerAvailable) { + DropdownMenuItem( + text = { + Text( + (if (uiState.useVendorAnalyzer) "✓ " else " ") + + stringResource(R.string.profile_add_scan_use_vendor_analyzer), + ) + }, + onClick = { + viewModel.toggleVendorAnalyzer() + showMenu = false + }, + ) + } + } + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) { + if (hasPermission) { + CameraPreview( + modifier = Modifier.fillMaxSize(), + viewModel = viewModel, + lifecycleOwner = lifecycleOwner, + cropArea = uiState.cropArea, + ) + } + + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + if (uiState.qrsMode && uiState.qrsProgress != null) { + val (decoded, total) = uiState.qrsProgress!! + val progress = if (total > 0) decoded.toFloat() / total.toFloat() / 1.2f else 0f + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)), + contentAlignment = Alignment.Center, + ) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.size(96.dp), + color = Color.White, + strokeWidth = 8.dp, + trackColor = Color.White.copy(alpha = 0.3f), + ) + if (total > 0) { + Text( + text = "${minOf(99, (progress * 100).toInt())}%", + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold, + ), + color = Color.White, + ) + } + Text( + text = "QRS", + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold, + ), + color = Color.White, + modifier = Modifier.offset(y = (-88).dp), + ) + } + } + } + } + } + } + + if (uiState.errorMessage != null) { + AlertDialog( + onDismissRequest = { viewModel.dismissError() }, + title = { Text(stringResource(android.R.string.dialog_alert_title)) }, + text = { Text(uiState.errorMessage ?: "") }, + confirmButton = { + TextButton(onClick = { viewModel.dismissError() }) { + Text(stringResource(android.R.string.ok)) + } + }, + ) + } +} + +@Composable +private fun CameraPreview( + modifier: Modifier = Modifier, + viewModel: QRScanViewModel, + lifecycleOwner: LifecycleOwner, + cropArea: QRCodeCropArea?, +) { + var previewView by remember { mutableStateOf(null) } + + DisposableEffect(previewView) { + previewView?.let { view -> + view.implementationMode = PreviewView.ImplementationMode.COMPATIBLE + viewModel.startCamera(lifecycleOwner, view) + } + onDispose { } + } + + Box(modifier = modifier) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + PreviewView(ctx).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + previewView = this + + previewStreamState.observe(lifecycleOwner) { state -> + if (state == PreviewView.StreamState.STREAMING) { + viewModel.onPreviewStreamStateChanged(true) + implementationMode = PreviewView.ImplementationMode.PERFORMANCE + } + } + } + }, + ) + + Canvas(modifier = Modifier.fillMaxSize()) { + val rect = cropArea?.let { mapCropAreaToPreview(it, size.width, size.height) } ?: return@Canvas + drawRect( + color = Color.White.copy(alpha = 0.85f), + topLeft = rect.topLeft, + size = rect.size, + style = Stroke(width = 2.dp.toPx()), + ) + } + } +} + +private fun mapCropAreaToPreview(area: QRCodeCropArea, viewWidth: Float, viewHeight: Float): Rect? { + if (viewWidth <= 0f || viewHeight <= 0f) return null + + val rotation = ((area.rotationDegrees % 360) + 360) % 360 + var rotLeft = area.left.toFloat() + var rotTop = area.top.toFloat() + var rotRight = area.right.toFloat() + var rotBottom = area.bottom.toFloat() + var imageWidth = area.imageWidth.toFloat() + var imageHeight = area.imageHeight.toFloat() + when (rotation) { + 90 -> { + rotLeft = (area.imageHeight - area.bottom).toFloat() + rotTop = area.left.toFloat() + rotRight = (area.imageHeight - area.top).toFloat() + rotBottom = area.right.toFloat() + imageWidth = area.imageHeight.toFloat() + imageHeight = area.imageWidth.toFloat() + } + 180 -> { + rotLeft = (area.imageWidth - area.right).toFloat() + rotTop = (area.imageHeight - area.bottom).toFloat() + rotRight = (area.imageWidth - area.left).toFloat() + rotBottom = (area.imageHeight - area.top).toFloat() + } + 270 -> { + rotLeft = area.top.toFloat() + rotTop = (area.imageWidth - area.right).toFloat() + rotRight = area.bottom.toFloat() + rotBottom = (area.imageWidth - area.left).toFloat() + imageWidth = area.imageHeight.toFloat() + imageHeight = area.imageWidth.toFloat() + } + } + + if (imageWidth <= 0f || imageHeight <= 0f) return null + + val scale = max(viewWidth / imageWidth, viewHeight / imageHeight) + val dx = (viewWidth - imageWidth * scale) / 2f + val dy = (viewHeight - imageHeight * scale) / 2f + + val left = rotLeft * scale + dx + val top = rotTop * scale + dy + val right = rotRight * scale + dx + val bottom = rotBottom * scale + dy + + val clampedLeft = left.coerceIn(0f, viewWidth) + val clampedTop = top.coerceIn(0f, viewHeight) + val clampedRight = right.coerceIn(0f, viewWidth) + val clampedBottom = bottom.coerceIn(0f, viewHeight) + + if (clampedRight - clampedLeft < 4f || clampedBottom - clampedTop < 4f) return null + + return Rect(clampedLeft, clampedTop, clampedRight, clampedBottom) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt new file mode 100644 index 0000000000..c84759efbd --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Connection.kt @@ -0,0 +1,114 @@ +package io.nekohasekai.sfa.compose.model + +import androidx.compose.runtime.Immutable +import io.nekohasekai.sfa.ktx.toList +import io.nekohasekai.libbox.Connection as LibboxConnection +import io.nekohasekai.libbox.ProcessInfo as LibboxProcessInfo + +@Immutable +data class ProcessInfo(val processId: Long, val userId: Int, val userName: String, val processPath: String, val packageName: String) { + companion object { + fun from(processInfo: LibboxProcessInfo?): ProcessInfo? { + if (processInfo == null) return null + return ProcessInfo( + processId = processInfo.processID, + userId = processInfo.userID, + userName = processInfo.userName ?: "", + processPath = processInfo.processPath ?: "", + packageName = processInfo.packageName ?: "", + ) + } + } +} + +@Immutable +data class Connection( + val id: String, + val inbound: String, + val inboundType: String, + val ipVersion: Int, + val network: String, + val source: String, + val destination: String, + val domain: String, + val displayDestination: String, + val protocolName: String, + val user: String, + val fromOutbound: String, + val createdAt: Long, + val closedAt: Long?, + val upload: Long, + val download: Long, + val uploadTotal: Long, + val downloadTotal: Long, + val rule: String, + val outbound: String, + val outboundType: String, + val chain: List, + val processInfo: ProcessInfo?, +) { + val isActive: Boolean get() = closedAt == null || closedAt == 0L + + fun performSearch(content: String): Boolean { + if (content.isBlank()) return true + for (item in content.trim().split(" ").filter { it.isNotEmpty() }) { + val parts = item.split(":", limit = 2) + if (parts.size == 2) { + if (!performSearchType(parts[0], parts[1])) return false + } else { + if (!performSearchPlain(item)) return false + } + } + return true + } + + private fun performSearchPlain(content: String): Boolean = destination.contains(content, ignoreCase = true) || + domain.contains(content, ignoreCase = true) || + outbound.contains(content, ignoreCase = true) || + rule.contains(content, ignoreCase = true) || + processInfo?.packageName?.contains(content, ignoreCase = true) == true + + private fun performSearchType(type: String, value: String): Boolean = when (type) { + "network" -> network.equals(value, ignoreCase = true) + "inbound" -> inbound.contains(value, ignoreCase = true) + "inbound.type" -> inboundType.equals(value, ignoreCase = true) + "source" -> source.contains(value, ignoreCase = true) + "destination" -> destination.contains(value, ignoreCase = true) + "outbound" -> outbound.contains(value, ignoreCase = true) + "outbound.type" -> outboundType.equals(value, ignoreCase = true) + "rule" -> rule.contains(value, ignoreCase = true) + "protocol" -> protocolName.equals(value, ignoreCase = true) + "user" -> user.contains(value, ignoreCase = true) + "package" -> processInfo?.packageName?.contains(value, ignoreCase = true) == true + "chain" -> chain.any { it.contains(value, ignoreCase = true) } + else -> false + } + + companion object { + fun from(connection: LibboxConnection): Connection = Connection( + id = connection.id, + inbound = connection.inbound, + inboundType = connection.inboundType, + ipVersion = connection.ipVersion, + network = connection.network, + source = connection.source, + destination = connection.destination, + domain = connection.domain, + displayDestination = connection.displayDestination(), + protocolName = connection.protocol, + user = connection.user, + fromOutbound = connection.fromOutbound, + createdAt = connection.createdAt, + closedAt = if (connection.closedAt > 0) connection.closedAt else null, + upload = connection.uplink, + download = connection.downlink, + uploadTotal = connection.uplinkTotal, + downloadTotal = connection.downlinkTotal, + rule = connection.rule, + outbound = connection.outbound, + outboundType = connection.outboundType, + chain = connection.chain().toList(), + processInfo = ProcessInfo.from(connection.processInfo), + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/ConnectionFilters.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/ConnectionFilters.kt new file mode 100644 index 0000000000..3a735702e0 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/ConnectionFilters.kt @@ -0,0 +1,15 @@ +package io.nekohasekai.sfa.compose.model + +import io.nekohasekai.libbox.Libbox + +enum class ConnectionStateFilter(val libboxValue: Int) { + All(Libbox.ConnectionStateAll.toInt()), + Active(Libbox.ConnectionStateActive.toInt()), + Closed(Libbox.ConnectionStateClosed.toInt()), +} + +enum class ConnectionSort { + ByDate, + ByTraffic, + ByTrafficTotal, +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Groups.kt similarity index 78% rename from sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt rename to sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Groups.kt index 4a9cb19f0c..5c92160977 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/Groups.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/model/Groups.kt @@ -1,5 +1,6 @@ -package io.nekohasekai.sfa.ui.dashboard +package io.nekohasekai.sfa.compose.model +import androidx.compose.runtime.Immutable import io.nekohasekai.libbox.OutboundGroup import io.nekohasekai.libbox.OutboundGroupItem import io.nekohasekai.libbox.OutboundGroupItemIterator @@ -10,7 +11,7 @@ data class Group( val selectable: Boolean, var selected: String, var isExpand: Boolean, - var items: List, + val items: List, ) { constructor(item: OutboundGroup) : this( item.tag, @@ -22,12 +23,8 @@ data class Group( ) } -data class GroupItem( - val tag: String, - val type: String, - val urlTestTime: Long, - val urlTestDelay: Int, -) { +@Immutable +data class GroupItem(val tag: String, val type: String, val urlTestTime: Long, val urlTestDelay: Int) { constructor(item: OutboundGroupItem) : this( item.tag, item.type, @@ -42,4 +39,4 @@ internal fun OutboundGroupItemIterator.toList(): List { list.add(next()) } return list -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt new file mode 100644 index 0000000000..27456b99cf --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/NavigationDestinations.kt @@ -0,0 +1,50 @@ +package io.nekohasekai.sfa.compose.navigation + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.TextSnippet +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.ui.graphics.vector.ImageVector +import io.nekohasekai.sfa.R + +sealed class Screen(val route: String, @StringRes val titleRes: Int, val icon: ImageVector) { + object Dashboard : Screen( + route = "dashboard", + titleRes = R.string.title_dashboard, + icon = Icons.Default.Dashboard, + ) + + object Log : Screen( + route = "log", + titleRes = R.string.title_log, + icon = Icons.AutoMirrored.Default.TextSnippet, + ) + + object Groups : Screen( + route = "groups", + titleRes = R.string.title_groups, + icon = Icons.Default.Folder, + ) + + object Connections : Screen( + route = "connections", + titleRes = R.string.title_connections, + icon = Icons.Default.SwapVert, + ) + + object Settings : Screen( + route = "settings", + titleRes = R.string.title_settings, + icon = Icons.Default.Settings, + ) +} + +val bottomNavigationScreens = + listOf( + Screen.Dashboard, + Screen.Log, + Screen.Settings, + ) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt new file mode 100644 index 0000000000..54d2dcddd1 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/ProfileRoutes.kt @@ -0,0 +1,11 @@ +package io.nekohasekai.sfa.compose.navigation + +data class NewProfileArgs(val importName: String? = null, val importUrl: String? = null, val qrsData: ByteArray? = null) + +object ProfileRoutes { + const val NewProfile = "profile/new" + const val EditProfile = "profile/edit/{profileId}" + const val EditProfileBase = "profile/edit" + + fun editProfile(profileId: Long): String = "$EditProfileBase/$profileId" +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt new file mode 100644 index 0000000000..2f46d1774d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/navigation/SFANavigation.kt @@ -0,0 +1,297 @@ +package io.nekohasekai.sfa.compose.navigation + +import android.net.Uri +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import io.nekohasekai.sfa.compose.screen.configuration.NewProfileScreen +import io.nekohasekai.sfa.compose.screen.connections.ConnectionDetailsRoute +import io.nekohasekai.sfa.compose.screen.connections.ConnectionsPage +import io.nekohasekai.sfa.compose.screen.connections.ConnectionsViewModel +import io.nekohasekai.sfa.compose.screen.dashboard.DashboardScreen +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.HookLogScreen +import io.nekohasekai.sfa.compose.screen.log.LogScreen +import io.nekohasekai.sfa.compose.screen.log.LogViewModel +import io.nekohasekai.sfa.compose.screen.privilegesettings.PrivilegeSettingsManageScreen +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.PrivilegeSettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.ProfileOverrideScreen +import io.nekohasekai.sfa.compose.screen.settings.ServiceSettingsScreen +import io.nekohasekai.sfa.compose.screen.settings.SettingsScreen +import io.nekohasekai.sfa.constant.Status + +private val slideInFromRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300)) +} + +private val slideOutToRight: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) +} + +private val slideInFromLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.EnterTransition = { + slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Right, animationSpec = tween(300)) +} + +private val slideOutToLeft: AnimatedContentTransitionScope<*>.() -> androidx.compose.animation.ExitTransition = { + slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Left, animationSpec = tween(300)) +} + +@Composable +fun SFANavHost( + navController: NavHostController, + serviceStatus: Status = Status.Stopped, + showStartFab: Boolean = false, + showStatusBar: Boolean = false, + newProfileArgs: NewProfileArgs = NewProfileArgs(), + onClearNewProfileArgs: () -> Unit = {}, + onOpenNewProfile: (NewProfileArgs) -> Unit = {}, + dashboardViewModel: DashboardViewModel? = null, + logViewModel: LogViewModel? = null, + groupsViewModel: GroupsViewModel? = null, + connectionsViewModel: ConnectionsViewModel? = null, + modifier: Modifier = Modifier, +) { + NavHost( + navController = navController, + startDestination = Screen.Dashboard.route, + modifier = modifier, + ) { + composable(Screen.Dashboard.route) { + if (dashboardViewModel != null) { + DashboardScreen( + serviceStatus = serviceStatus, + showStartFab = showStartFab, + showStatusBar = showStatusBar, + onOpenNewProfile = onOpenNewProfile, + viewModel = dashboardViewModel, + ) + } else { + DashboardScreen( + serviceStatus = serviceStatus, + showStartFab = showStartFab, + showStatusBar = showStatusBar, + onOpenNewProfile = onOpenNewProfile, + ) + } + } + + composable(Screen.Log.route) { + if (logViewModel != null) { + LogScreen( + serviceStatus = serviceStatus, + showStartFab = showStartFab, + showStatusBar = showStatusBar, + viewModel = logViewModel, + ) + } else { + LogScreen( + serviceStatus = serviceStatus, + showStartFab = showStartFab, + showStatusBar = showStatusBar, + ) + } + } + + composable(Screen.Groups.route) { + if (groupsViewModel != null) { + GroupsCard( + serviceStatus = serviceStatus, + viewModel = groupsViewModel, + showTopBar = true, + modifier = Modifier.fillMaxSize(), + ) + } else { + GroupsCard( + serviceStatus = serviceStatus, + showTopBar = true, + modifier = Modifier.fillMaxSize(), + ) + } + } + + composable(Screen.Connections.route) { + if (connectionsViewModel != null) { + ConnectionsPage( + serviceStatus = serviceStatus, + viewModel = connectionsViewModel, + showTitle = false, + showTopBar = true, + onConnectionClick = { connectionId -> + navController.navigate("connections/detail/${Uri.encode(connectionId)}") + }, + modifier = Modifier.fillMaxSize(), + ) + } else { + ConnectionsPage( + serviceStatus = serviceStatus, + showTitle = false, + showTopBar = true, + onConnectionClick = { connectionId -> + navController.navigate("connections/detail/${Uri.encode(connectionId)}") + }, + modifier = Modifier.fillMaxSize(), + ) + } + } + + composable(ProfileRoutes.NewProfile) { + DisposableEffect(Unit) { + onDispose { onClearNewProfileArgs() } + } + NewProfileScreen( + importName = newProfileArgs.importName, + importUrl = newProfileArgs.importUrl, + qrsData = newProfileArgs.qrsData, + onNavigateBack = { + onClearNewProfileArgs() + navController.navigateUp() + }, + onProfileCreated = { profileId -> + onClearNewProfileArgs() + navController.navigate(ProfileRoutes.editProfile(profileId)) { + popUpTo(ProfileRoutes.NewProfile) { + inclusive = true + } + } + }, + ) + } + + composable( + route = ProfileRoutes.EditProfile, + arguments = listOf( + navArgument("profileId") { + type = NavType.LongType + }, + ), + ) { backStackEntry -> + val profileId = backStackEntry.arguments?.getLong("profileId") ?: -1L + EditProfileRoute( + profileId = profileId, + onNavigateBack = { navController.navigateUp() }, + modifier = Modifier.fillMaxSize(), + ) + } + + composable("connections/detail/{connectionId}") { backStackEntry -> + val connectionId = backStackEntry.arguments?.getString("connectionId") + if (connectionId != null) { + if (connectionsViewModel != null) { + ConnectionDetailsRoute( + connectionId = connectionId, + serviceStatus = serviceStatus, + viewModel = connectionsViewModel, + onBack = { navController.navigateUp() }, + modifier = Modifier.fillMaxSize(), + ) + } else { + ConnectionDetailsRoute( + connectionId = connectionId, + serviceStatus = serviceStatus, + onBack = { navController.navigateUp() }, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + composable(Screen.Settings.route) { + SettingsScreen(navController = navController) + } + + // Settings subscreens with slide animations + composable( + route = "settings/app", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + AppSettingsScreen(navController = navController) + } + + composable( + route = "settings/core", + enterTransition = slideInFromRight, + exitTransition = slideOutToRight, + popEnterTransition = slideInFromRight, + popExitTransition = slideOutToRight, + ) { + CoreSettingsScreen(navController = navController) + } + + composable( + route = "settings/service", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + ServiceSettingsScreen(navController = navController) + } + + composable( + route = "settings/profile_override", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + ProfileOverrideScreen(navController = navController) + } + + composable( + route = "settings/profile_override/manage", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + PerAppProxyScreen(onBack = { navController.navigateUp() }) + } + + composable( + route = "settings/privilege", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + PrivilegeSettingsScreen(navController = navController, serviceStatus = serviceStatus) + } + + composable( + route = "settings/privilege/manage", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + PrivilegeSettingsManageScreen(onBack = { navController.navigateUp() }) + } + + composable( + route = "settings/privilege/logs", + enterTransition = slideInFromRight, + exitTransition = slideOutToLeft, + popEnterTransition = slideInFromLeft, + popExitTransition = slideOutToRight, + ) { + HookLogScreen(onBack = { navController.navigateUp() }) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt new file mode 100644 index 0000000000..72559fd1a0 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileScreen.kt @@ -0,0 +1,590 @@ +package io.nekohasekai.sfa.compose.screen.configuration + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +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.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +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.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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.OutlinedButton +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.SelectableMessageDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewProfileScreen( + importName: String? = null, + importUrl: String? = null, + qrsData: ByteArray? = null, + onNavigateBack: () -> Unit, + onProfileCreated: (profileId: Long) -> Unit, + viewModel: NewProfileViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + LaunchedEffect(importName, importUrl, qrsData) { + if (qrsData != null) { + viewModel.initializeFromQRSImport(importName, qrsData) + } else { + viewModel.initializeFromQRImport(importName, importUrl) + } + } + + // File picker launcher + val filePickerLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + uri?.let { + val fileName = + context.contentResolver.query(it, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndexOrThrow("_display_name") + cursor.moveToFirst() + cursor.getString(nameIndex) + } + viewModel.setImportUri(it, fileName) + } + } + + // Error dialog state + var showErrorDialog by remember { mutableStateOf(false) } + + // Handle success + LaunchedEffect(uiState.isSuccess, uiState.createdProfile) { + if (uiState.isSuccess) { + uiState.createdProfile?.let { profile -> + onProfileCreated(profile.id) + } + } + } + + // Show error dialog when there's an error message + LaunchedEffect(uiState.errorMessage) { + if (uiState.errorMessage != null) { + showErrorDialog = true + } + } + + // Error dialog + if (showErrorDialog) { + SelectableMessageDialog( + title = stringResource(R.string.error_title), + message = uiState.errorMessage ?: "", + onDismiss = { + showErrorDialog = false + viewModel.clearError() + }, + ) + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_new_profile)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + + val bottomInset = + with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val bottomBarPadding = 88.dp + bottomInset + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .padding(bottom = bottomBarPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Profile Name + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.basic_information), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + OutlinedTextField( + value = uiState.name, + onValueChange = viewModel::updateName, + label = { Text(stringResource(R.string.profile_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = uiState.nameError != null, + supportingText = { + uiState.nameError?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + } + } + + // Profile Type Selection + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(R.string.profile_type), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((-1).dp), // Overlap borders + ) { + OutlinedButton( + onClick = { viewModel.updateProfileType(ProfileType.Local) }, + modifier = Modifier.weight(1f), + shape = + RoundedCornerShape( + topStart = 12.dp, + bottomStart = 12.dp, + topEnd = 0.dp, + bottomEnd = 0.dp, + ), + colors = + if (uiState.profileType == ProfileType.Local) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + ButtonDefaults.outlinedButtonColors() + }, + border = + BorderStroke( + 1.dp, + if (uiState.profileType == ProfileType.Local) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + ), + ) { + Text(stringResource(R.string.profile_type_local)) + } + OutlinedButton( + onClick = { viewModel.updateProfileType(ProfileType.Remote) }, + modifier = Modifier.weight(1f), + shape = + RoundedCornerShape( + topStart = 0.dp, + bottomStart = 0.dp, + topEnd = 12.dp, + bottomEnd = 12.dp, + ), + colors = + if (uiState.profileType == ProfileType.Remote) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } else { + ButtonDefaults.outlinedButtonColors() + }, + border = + BorderStroke( + 1.dp, + if (uiState.profileType == ProfileType.Remote) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outline + }, + ), + ) { + Text(stringResource(R.string.profile_type_remote)) + } + } + } + } + + // Local Profile Options + AnimatedVisibility( + visible = uiState.profileType == ProfileType.Local, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.profile_source), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((-1).dp), // Overlap borders + ) { + OutlinedButton( + onClick = { viewModel.updateProfileSource(ProfileSource.CreateNew) }, + modifier = Modifier.weight(1f), + shape = + RoundedCornerShape( + topStart = 12.dp, + bottomStart = 12.dp, + topEnd = 0.dp, + bottomEnd = 0.dp, + ), + colors = + if (uiState.profileSource == ProfileSource.CreateNew) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + ButtonDefaults.outlinedButtonColors() + }, + border = + BorderStroke( + 1.dp, + if (uiState.profileSource == ProfileSource.CreateNew) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.outline + }, + ), + ) { + Icon( + Icons.Default.CreateNewFolder, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.profile_source_create_new)) + } + OutlinedButton( + onClick = { viewModel.updateProfileSource(ProfileSource.Import) }, + modifier = Modifier.weight(1f), + shape = + RoundedCornerShape( + topStart = 0.dp, + bottomStart = 0.dp, + topEnd = 12.dp, + bottomEnd = 12.dp, + ), + colors = + if (uiState.profileSource == ProfileSource.Import) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } else { + ButtonDefaults.outlinedButtonColors() + }, + border = + BorderStroke( + 1.dp, + if (uiState.profileSource == ProfileSource.Import) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.outline + }, + ), + ) { + Icon( + Icons.Default.FileUpload, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.profile_source_import)) + } + } + + AnimatedVisibility( + visible = uiState.profileSource == ProfileSource.Import, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + OutlinedCard( + onClick = { filePickerLauncher.launch("*/*") }, + modifier = Modifier.fillMaxWidth(), + border = + BorderStroke( + 1.dp, + if (uiState.importError != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.outline + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + Icons.Default.FileUpload, + contentDescription = null, + tint = + if (uiState.importError != null) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.primary + }, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = uiState.importFileName ?: stringResource(R.string.profile_import_file), + style = MaterialTheme.typography.bodyMedium, + ) + if (uiState.importFileName != null) { + Text( + text = stringResource(R.string.group_selected_title), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + uiState.importError?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 16.dp), + ) + } + } + } + } + } + } + + // Remote Profile Options + AnimatedVisibility( + visible = uiState.profileType == ProfileType.Remote, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.remote_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + + OutlinedTextField( + value = uiState.remoteUrl, + onValueChange = viewModel::updateRemoteUrl, + label = { Text(stringResource(R.string.profile_url)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = uiState.remoteUrlError != null, + supportingText = { + uiState.remoteUrlError?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + ) + } + }, + ) + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.profile_auto_update), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = uiState.autoUpdate, + onCheckedChange = viewModel::updateAutoUpdate, + ) + } + + AnimatedVisibility(visible = uiState.autoUpdate) { + OutlinedTextField( + value = uiState.autoUpdateInterval.toString(), + onValueChange = viewModel::updateAutoUpdateInterval, + label = { Text(stringResource(R.string.profile_auto_update_interval)) }, + supportingText = { Text(stringResource(R.string.profile_auto_update_interval_minimum_hint)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + ) + } + } + } + } + } + + Surface( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(16.dp), + ) { + Button( + onClick = { viewModel.validateAndCreateProfile() }, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isSaving, + ) { + if (uiState.isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.profile_create)) + } + } + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt new file mode 100644 index 0000000000..11c35bb946 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/NewProfileViewModel.kt @@ -0,0 +1,319 @@ +package io.nekohasekai.sfa.compose.screen.configuration + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.UpdateProfileWork +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.utils.HTTPClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.InputStream +import java.util.Date + +data class NewProfileUiState( + val name: String = "", + val profileType: ProfileType = ProfileType.Local, + val profileSource: ProfileSource = ProfileSource.CreateNew, + // Remote profile fields + val remoteUrl: String = "", + val autoUpdate: Boolean = true, + val autoUpdateInterval: Int = 60, + // File import + val importUri: Uri? = null, + val importFileName: String? = null, + // QRS import + val qrsData: ByteArray? = null, + // State + val isLoading: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null, + val isSuccess: Boolean = false, + val createdProfile: Profile? = null, + // Field errors + val nameError: String? = null, + val remoteUrlError: String? = null, + val importError: String? = null, +) + +enum class ProfileType { + Local, + Remote, +} + +enum class ProfileSource { + CreateNew, + Import, +} + +class NewProfileViewModel(application: Application) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(NewProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun initializeFromQRImport(name: String?, url: String?) { + if (name != null && url != null) { + _uiState.update { + it.copy( + name = name, + profileType = ProfileType.Remote, + remoteUrl = url, + ) + } + } + } + + fun initializeFromQRSImport(name: String?, qrsData: ByteArray) { + _uiState.update { + it.copy( + name = name ?: "", + profileType = ProfileType.Local, + profileSource = ProfileSource.Import, + qrsData = qrsData, + ) + } + } + + fun updateName(name: String) { + _uiState.update { + it.copy( + name = name, + nameError = if (name.isNotBlank()) null else it.nameError, + ) + } + } + + fun updateProfileType(type: ProfileType) { + _uiState.update { it.copy(profileType = type) } + } + + fun updateProfileSource(source: ProfileSource) { + _uiState.update { + it.copy( + profileSource = source, + importError = null, // Clear import error when changing source + ) + } + } + + fun updateRemoteUrl(url: String) { + _uiState.update { + it.copy( + remoteUrl = url, + remoteUrlError = if (url.isNotBlank()) null else it.remoteUrlError, + ) + } + } + + fun updateAutoUpdate(enabled: Boolean) { + _uiState.update { it.copy(autoUpdate = enabled) } + } + + fun updateAutoUpdateInterval(interval: String) { + val intValue = interval.toIntOrNull() ?: 60 + _uiState.update { it.copy(autoUpdateInterval = intValue.coerceAtLeast(15)) } + } + + fun setImportUri(uri: Uri, fileName: String?) { + _uiState.update { + it.copy( + importUri = uri, + importFileName = fileName, + importError = null, // Clear error when file is selected + name = + if (it.name.isEmpty()) { + fileName?.substringBeforeLast(".") ?: "Imported Profile" + } else { + it.name + }, + ) + } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + fun validateAndCreateProfile(): Boolean { + val state = _uiState.value + val context = getApplication() + + // Clear previous errors + _uiState.update { + it.copy( + nameError = null, + remoteUrlError = null, + importError = null, + ) + } + + var hasError = false + + // Validate name + if (state.name.isBlank()) { + _uiState.update { it.copy(nameError = context.getString(R.string.profile_input_required)) } + hasError = true + } + + // Validate based on profile type + when (state.profileType) { + ProfileType.Local -> { + if (state.profileSource == ProfileSource.Import && state.importUri == null && state.qrsData == null) { + _uiState.update { it.copy(importError = context.getString(R.string.profile_input_required)) } + hasError = true + } + } + ProfileType.Remote -> { + if (state.remoteUrl.isBlank()) { + _uiState.update { it.copy(remoteUrlError = context.getString(R.string.profile_input_required)) } + hasError = true + } + } + } + + if (hasError) { + return false + } + + // If validation passes, create the profile + createProfile() + return true + } + + private fun createProfile() { + viewModelScope.launch { + val state = _uiState.value + _uiState.update { it.copy(isSaving = true, errorMessage = null) } + + try { + val profile = + withContext(Dispatchers.IO) { + when (state.profileType) { + ProfileType.Local -> createLocalProfile(state) + ProfileType.Remote -> createRemoteProfile(state) + } + } + + _uiState.update { + it.copy( + isSaving = false, + isSuccess = true, + createdProfile = profile, + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isSaving = false, + errorMessage = e.message ?: "Unknown error", + ) + } + } + } + } + + private suspend fun createLocalProfile(state: NewProfileUiState): Profile { + val context = getApplication() + val typedProfile = + TypedProfile().apply { + type = TypedProfile.Type.Local + } + + val profile = + Profile(name = state.name, typed = typedProfile).apply { + userOrder = ProfileManager.nextOrder() + } + + val fileID = ProfileManager.nextFileID() + val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "$fileID.json") + typedProfile.path = configFile.path + + // Get config content + val configContent = + when (state.profileSource) { + ProfileSource.CreateNew -> "{}" + ProfileSource.Import -> { + if (state.qrsData != null) { + val content = Libbox.decodeProfileContent(state.qrsData) + content.config + } else { + state.importUri?.let { uri -> + val sourceURL = uri.toString() + when { + sourceURL.startsWith("content://") -> { + val inputStream = context.contentResolver.openInputStream(uri) as InputStream + inputStream.use { it.bufferedReader().readText() } + } + sourceURL.startsWith("file://") -> { + File(Uri.parse(sourceURL).path!!).readText() + } + sourceURL.startsWith("http://") || sourceURL.startsWith("https://") -> { + HTTPClient().use { it.getString(sourceURL) } + } + else -> throw Exception("Unsupported source: $sourceURL") + } + } ?: "{}" + } + } + } + + // Validate config + Libbox.checkConfig(configContent) + configFile.writeText(configContent) + + // Create profile in database and select it + ProfileManager.create(profile, andSelect = true) + + return profile + } + + private suspend fun createRemoteProfile(state: NewProfileUiState): Profile { + val context = getApplication() + val typedProfile = + TypedProfile().apply { + type = TypedProfile.Type.Remote + remoteURL = state.remoteUrl + autoUpdate = state.autoUpdate + autoUpdateInterval = state.autoUpdateInterval + lastUpdated = Date() + } + + val profile = + Profile(name = state.name, typed = typedProfile).apply { + userOrder = ProfileManager.nextOrder() + } + + val fileID = ProfileManager.nextFileID() + val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "$fileID.json") + typedProfile.path = configFile.path + + // Fetch initial config - this MUST succeed for remote profiles + val content = HTTPClient().use { it.getString(state.remoteUrl) } + Libbox.checkConfig(content) + val configContent = content + + configFile.writeText(configContent) + + // Create profile in database and select it + ProfileManager.create(profile, andSelect = true) + + // Reconfigure updater if auto-update is enabled + if (state.autoUpdate) { + UpdateProfileWork.reconfigureUpdater() + } + + return profile + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt new file mode 100644 index 0000000000..0a8e9a5eae --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/configuration/ProfileImportHandler.kt @@ -0,0 +1,386 @@ +package io.nekohasekai.sfa.compose.screen.configuration + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.ProfileContent +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.database.TypedProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.util.Date + +class ProfileImportHandler(private val context: Context) { + sealed class ImportResult { + data class Success(val profile: Profile) : ImportResult() + + data class Error(val message: String) : ImportResult() + } + + sealed class QRCodeParseResult { + data class RemoteProfile(val name: String, val host: String, val url: String) : QRCodeParseResult() + + data class LocalProfile(val name: String) : QRCodeParseResult() + + data class Error(val message: String) : QRCodeParseResult() + } + + sealed class QRSParseResult { + data class Success(val name: String) : QRSParseResult() + + data class Error(val message: String) : QRSParseResult() + } + + sealed class UriParseResult { + data class Success(val name: String) : UriParseResult() + + data class Error(val message: String) : UriParseResult() + } + + suspend fun importFromUri(uri: Uri): ImportResult = withContext(Dispatchers.IO) { + try { + val data = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: return@withContext ImportResult.Error(context.getString(R.string.error_empty_file)) + + // Get the filename from the URI + val filename = getFileNameFromUri(uri) + + // Try to detect if it's a JSON configuration file + val dataString = String(data) + if (isJsonConfiguration(dataString)) { + // It's a JSON configuration, import it directly as a local profile + return@withContext importJsonConfiguration(dataString, filename) + } + + // Try to decode as ProfileContent (the old way) + val content = + try { + Libbox.decodeProfileContent(data) + } catch (e: Exception) { + // If it fails, try one more time as JSON + if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) { + return@withContext importJsonConfiguration(dataString, filename) + } + return@withContext ImportResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + + importProfile(content) + } catch (e: Exception) { + ImportResult.Error(e.message ?: "Unknown error") + } + } + + suspend fun parseUri(uri: Uri): UriParseResult = withContext(Dispatchers.IO) { + try { + val data = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: return@withContext UriParseResult.Error(context.getString(R.string.error_empty_file)) + + val filename = getFileNameFromUri(uri) + val dataString = String(data) + + if (isJsonConfiguration(dataString)) { + return@withContext UriParseResult.Success(name = filename) + } + + val content = + try { + Libbox.decodeProfileContent(data) + } catch (e: Exception) { + if (dataString.trimStart().startsWith("{") || dataString.trimStart().startsWith("[")) { + return@withContext UriParseResult.Success(name = filename) + } + return@withContext UriParseResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + + UriParseResult.Success(name = content.name) + } catch (e: Exception) { + UriParseResult.Error(e.message ?: "Unknown error") + } + } + + suspend fun parseQRCode(data: String): QRCodeParseResult = withContext(Dispatchers.IO) { + try { + // Check if it's a sing-box remote profile import link + if (data.startsWith("sing-box://import-remote-profile")) { + try { + val profileInfo = Libbox.parseRemoteProfileImportLink(data) + return@withContext QRCodeParseResult.RemoteProfile( + name = profileInfo.name, + host = profileInfo.host, + url = profileInfo.url, + ) + } catch (e: Exception) { + return@withContext QRCodeParseResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + } + + // Check if it's a direct URL + if (data.startsWith("http://") || data.startsWith("https://")) { + val profileName = extractProfileNameFromUrl(data) + return@withContext QRCodeParseResult.RemoteProfile( + name = profileName, + host = extractHostFromUrl(data), + url = data, + ) + } + + // Try to decode as profile content + val content = + try { + Libbox.decodeProfileContent(data.toByteArray()) + } catch (e: Exception) { + return@withContext QRCodeParseResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + + return@withContext QRCodeParseResult.LocalProfile(name = content.name) + } catch (e: Exception) { + QRCodeParseResult.Error(e.message ?: "Unknown error") + } + } + + suspend fun importFromQRCode(data: String): ImportResult = withContext(Dispatchers.IO) { + try { + // Check if it's a sing-box remote profile import link + if (data.startsWith("sing-box://import-remote-profile")) { + try { + val profileInfo = Libbox.parseRemoteProfileImportLink(data) + return@withContext importRemoteProfile(profileInfo.name, profileInfo.url) + } catch (e: Exception) { + return@withContext ImportResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + } + + // Check if it's a URL or direct profile content + if (data.startsWith("http://") || data.startsWith("https://")) { + // Handle remote profile URL + val profileName = extractProfileNameFromUrl(data) + importRemoteProfile(profileName, data) + } else { + // Try to decode as profile content + val content = + try { + Libbox.decodeProfileContent(data.toByteArray()) + } catch (e: Exception) { + return@withContext ImportResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + importProfile(content) + } + } catch (e: Exception) { + ImportResult.Error(e.message ?: "Unknown error") + } + } + + suspend fun parseQRSData(data: ByteArray): QRSParseResult = withContext(Dispatchers.IO) { + try { + val content = try { + Libbox.decodeProfileContent(data) + } catch (e: Exception) { + return@withContext QRSParseResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + QRSParseResult.Success(name = content.name) + } catch (e: Exception) { + QRSParseResult.Error(e.message ?: "Unknown error") + } + } + + suspend fun importFromQRSData(data: ByteArray): ImportResult = withContext(Dispatchers.IO) { + try { + val content = try { + Libbox.decodeProfileContent(data) + } catch (e: Exception) { + return@withContext ImportResult.Error( + context.getString(R.string.error_decode_profile, e.message), + ) + } + importProfile(content) + } catch (e: Exception) { + ImportResult.Error(e.message ?: "Unknown error") + } + } + + private suspend fun importProfile(content: ProfileContent): ImportResult { + val typedProfile = TypedProfile() + val profile = Profile(name = content.name, typed = typedProfile) + profile.userOrder = ProfileManager.nextOrder() + + when (content.type) { + Libbox.ProfileTypeLocal -> { + typedProfile.type = TypedProfile.Type.Local + } + Libbox.ProfileTypeiCloud -> { + return ImportResult.Error(context.getString(R.string.icloud_profile_unsupported)) + } + Libbox.ProfileTypeRemote -> { + typedProfile.type = TypedProfile.Type.Remote + typedProfile.remoteURL = content.remotePath + typedProfile.autoUpdate = content.autoUpdate + typedProfile.autoUpdateInterval = content.autoUpdateInterval + typedProfile.lastUpdated = Date(content.lastUpdated) + } + } + + // Save config file + val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "${profile.userOrder}.json") + configFile.writeText(content.config) + typedProfile.path = configFile.path + + // Create profile in database and select it + ProfileManager.create(profile, andSelect = true) + + return ImportResult.Success(profile) + } + + private suspend fun importRemoteProfile(name: String, url: String): ImportResult { + val typedProfile = + TypedProfile().apply { + type = TypedProfile.Type.Remote + remoteURL = url + autoUpdate = true + autoUpdateInterval = 60 + lastUpdated = Date() + } + + val profile = + Profile(name = name, typed = typedProfile).apply { + userOrder = ProfileManager.nextOrder() + } + + // Create empty config file for remote profile + val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "${profile.userOrder}.json") + configFile.writeText("{}") + typedProfile.path = configFile.path + + // Create profile in database and select it + ProfileManager.create(profile, andSelect = true) + + return ImportResult.Success(profile) + } + + private fun extractProfileNameFromUrl(url: String): String { + // Extract name from URL or use default + return url.substringAfterLast("/") + .substringBeforeLast(".") + .takeIf { it.isNotEmpty() } + ?: "Remote Profile" + } + + private fun extractHostFromUrl(url: String): String = try { + val uri = Uri.parse(url) + uri.host ?: url + } catch (e: Exception) { + url + } + + private fun getFileNameFromUri(uri: Uri): String { + var filename = "Imported Profile" + + // Try to get filename from content resolver + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0 && cursor.moveToFirst()) { + filename = cursor.getString(nameIndex) + ?.substringBeforeLast(".") // Remove extension + ?.takeIf { it.isNotEmpty() } + ?: filename + } + } + + // Fallback to getting from URI path + if (filename == "Imported Profile") { + uri.lastPathSegment?.let { segment -> + filename = segment + .substringBeforeLast(".") + .takeIf { it.isNotEmpty() } + ?: filename + } + } + + return filename + } + + private fun isJsonConfiguration(content: String): Boolean { + val trimmed = content.trim() + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + return false + } + + return try { + // Try to parse as JSON and check for sing-box configuration fields + val json = JSONObject(content) + // Check for common sing-box configuration fields + json.has("inbounds") || + json.has("outbounds") || + json.has("route") || + json.has("dns") || + json.has("experimental") + } catch (e: Exception) { + // If it's an array, it might still be valid + trimmed.startsWith("[") && trimmed.endsWith("]") + } + } + + private suspend fun importJsonConfiguration(jsonContent: String, profileName: String): ImportResult { + return try { + // Validate the JSON configuration using sing-box + try { + // Try to check the configuration + Libbox.checkConfig(jsonContent) + } catch (e: Exception) { + // Configuration validation failed + return ImportResult.Error( + context.getString(R.string.error_invalid_configuration, e.message), + ) + } + + // Create a local profile with the JSON configuration + val typedProfile = + TypedProfile().apply { + type = TypedProfile.Type.Local + } + + val profile = + Profile( + name = profileName.ifEmpty { "Imported Profile" }, + typed = typedProfile, + ).apply { + userOrder = ProfileManager.nextOrder() + } + + // Save the configuration file + val configDirectory = File(context.filesDir, "configs").also { it.mkdirs() } + val configFile = File(configDirectory, "${profile.userOrder}.json") + configFile.writeText(jsonContent) + typedProfile.path = configFile.path + + // Create profile in database and select it + ProfileManager.create(profile, andSelect = true) + + ImportResult.Success(profile) + } catch (e: Exception) { + ImportResult.Error(e.message ?: "Unknown error importing JSON configuration") + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt new file mode 100644 index 0000000000..ef572402cf --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionDetailsScreen.kt @@ -0,0 +1,364 @@ +package io.nekohasekai.sfa.compose.screen.connections + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +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.Velocity +import androidx.compose.ui.unit.dp +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compat.rememberOverscrollEffectCompat +import io.nekohasekai.sfa.compat.verticalScrollCompat +import io.nekohasekai.sfa.compose.model.Connection +import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun ConnectionDetailsScreen( + connection: Connection, + onBack: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, + showHeader: Boolean = true, + asSheet: Boolean = false, +) { + val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + var showMenu by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val scrollModifier = + if (asSheet) { + rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { + scrollState.value == 0 + } + } else { + Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(scrollState)) + } + + Column( + modifier = modifier + .fillMaxSize() + .then(scrollModifier) + .verticalScrollCompat(scrollState, overscrollEffect = if (asSheet) null else rememberOverscrollEffectCompat()), + ) { + if (showHeader) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + Text( + text = stringResource(R.string.connection_details), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + if (connection.isActive) { + Box { + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.connection_close)) }, + onClick = { + onClose() + showMenu = false + }, + ) + } + } + } + } + } + + DetailSection(title = stringResource(R.string.connection_section_basic)) { + DetailRow( + label = stringResource(R.string.connection_state), + value = if (connection.isActive) { + stringResource(R.string.connection_state_active) + } else { + stringResource(R.string.connection_state_closed) + }, + valueColor = if (connection.isActive) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.error + }, + ) + DetailRow( + label = stringResource(R.string.connection_created_at), + value = dateTimeFormat.format(Date(connection.createdAt)), + ) + if (!connection.isActive && connection.closedAt != null) { + DetailRow( + label = stringResource(R.string.connection_closed_at), + value = dateTimeFormat.format(Date(connection.closedAt)), + ) + DetailRow( + label = stringResource(R.string.connection_duration), + value = Libbox.formatDuration(connection.closedAt - connection.createdAt), + ) + } + DetailRow( + label = stringResource(R.string.connection_uplink), + value = Libbox.formatBytes(connection.uploadTotal), + ) + DetailRow( + label = stringResource(R.string.connection_downlink), + value = Libbox.formatBytes(connection.downloadTotal), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + DetailSection(title = stringResource(R.string.connection_section_metadata)) { + DetailRow( + label = stringResource(R.string.connection_inbound), + value = connection.inbound, + monospace = true, + ) + DetailRow( + label = stringResource(R.string.connection_inbound_type), + value = connection.inboundType, + monospace = true, + ) + DetailRow( + label = stringResource(R.string.connection_ip_version), + value = "IPv${connection.ipVersion}", + ) + DetailRow( + label = stringResource(R.string.connection_network), + value = connection.network.uppercase(), + ) + DetailRow( + label = stringResource(R.string.connection_source), + value = connection.source, + monospace = true, + ) + DetailRow( + label = stringResource(R.string.connection_destination), + value = connection.destination, + monospace = true, + ) + if (connection.domain.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.connection_domain), + value = connection.domain, + monospace = true, + ) + } + if (connection.protocolName.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.connection_protocol), + value = connection.protocolName, + monospace = true, + ) + } + if (connection.user.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.connection_user), + value = connection.user, + monospace = true, + ) + } + if (connection.fromOutbound.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.connection_from_outbound), + value = connection.fromOutbound, + monospace = true, + ) + } + if (connection.rule.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.connection_match_rule), + value = connection.rule, + monospace = true, + ) + } + DetailRow( + label = stringResource(R.string.connection_outbound), + value = connection.outbound, + monospace = true, + ) + DetailRow( + label = stringResource(R.string.connection_outbound_type), + value = connection.outboundType, + monospace = true, + ) + if (connection.chain.size > 1) { + DetailRow( + label = stringResource(R.string.connection_chain), + value = connection.chain.joinToString(" → "), + monospace = true, + ) + } + } + + connection.processInfo?.let { processInfo -> + if (processInfo.packageName.isNotEmpty() || + processInfo.processPath.isNotEmpty() || + processInfo.processId > 0 + ) { + Spacer(modifier = Modifier.height(16.dp)) + + DetailSection(title = stringResource(R.string.connection_section_process)) { + if (processInfo.processId > 0) { + DetailRow( + label = stringResource(R.string.connection_process_id), + value = processInfo.processId.toString(), + monospace = true, + ) + } + if (processInfo.userId >= 0) { + DetailRow( + label = stringResource(R.string.connection_user_id), + value = processInfo.userId.toString(), + monospace = true, + ) + } + if (processInfo.userName.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.connection_user_name), + value = processInfo.userName, + monospace = true, + ) + } + if (processInfo.processPath.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.connection_process_path), + value = processInfo.processPath, + monospace = true, + ) + } + if (processInfo.packageName.isNotEmpty()) { + DetailRow( + label = stringResource(R.string.connection_package_name), + value = processInfo.packageName, + monospace = true, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + } +} + +@Composable +private fun DetailSection(title: String, content: @Composable ColumnScope.() -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 8.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), + content = content, + ) + } + } +} + +@Composable +private fun DetailRow(label: String, value: String, monospace: Boolean = false, valueColor: Color = MaterialTheme.colorScheme.onSurface) { + 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), + ) + SelectionContainer(modifier = Modifier.weight(1f)) { + Text( + text = value, + style = if (monospace) { + MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + } else { + MaterialTheme.typography.bodyMedium + }, + color = valueColor, + textAlign = TextAlign.End, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun rememberBounceBlockingNestedScrollConnection(scrollState: ScrollState): NestedScrollConnection = remember(scrollState) { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = if (available.y < 0) available else Offset.Zero + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = if (available.y < 0) available else Velocity.Zero + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt new file mode 100644 index 0000000000..49dc7a0e59 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionItem.kt @@ -0,0 +1,221 @@ +package io.nekohasekai.sfa.compose.screen.connections + +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +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.TextOverflow +import androidx.compose.ui.unit.dp +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.model.Connection + +private fun Drawable.toBitmap(): Bitmap { + if (this is BitmapDrawable) return bitmap + val bitmap = Bitmap.createBitmap( + intrinsicWidth.coerceAtLeast(1), + intrinsicHeight.coerceAtLeast(1), + Bitmap.Config.ARGB_8888, + ) + val canvas = Canvas(bitmap) + setBounds(0, 0, canvas.width, canvas.height) + draw(canvas) + return bitmap +} + +data class AppInfo(val icon: ImageBitmap, val label: String) + +@Composable +private fun rememberAppInfo(packageName: String): AppInfo? { + val context = LocalContext.current + return remember(packageName) { + try { + val pm = context.packageManager + val appInfo = pm.getApplicationInfo(packageName, 0) + AppInfo( + icon = appInfo.loadIcon(pm).toBitmap().asImageBitmap(), + label = appInfo.loadLabel(pm).toString(), + ) + } catch (e: PackageManager.NameNotFoundException) { + null + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ConnectionItem(connection: Connection, onClick: () -> Unit, onClose: () -> Unit, modifier: Modifier = Modifier) { + var showContextMenu by remember { mutableStateOf(false) } + val packageName = connection.processInfo?.packageName?.takeIf { it.isNotEmpty() } + val appInfo = packageName?.let { rememberAppInfo(it) } + + Box(modifier = modifier) { + Card( + modifier = Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = { showContextMenu = true }, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + 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, + ) + } + + // Content column + Column(modifier = Modifier.weight(1f)) { + // Row 1: Title (destination + status) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "${connection.network.uppercase()} ${connection.displayDestination}", + style = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ), + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (connection.isActive) { + stringResource(R.string.connection_state_active) + } else { + stringResource(R.string.connection_state_closed) + }, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold), + color = if (connection.isActive) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.error + }, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // Row 2: Upload stats + inbound tag + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "↑ ${Libbox.formatBytes(connection.upload)}/s | ${Libbox.formatBytes(connection.uploadTotal)}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${connection.inboundType}/${connection.inbound}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Row 3: Download stats + outbound tag + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "↓ ${Libbox.formatBytes(connection.download)}/s | ${Libbox.formatBytes(connection.downloadTotal)}", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = connection.chain.firstOrNull() ?: connection.outbound, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + DropdownMenu( + expanded = showContextMenu, + onDismissRequest = { showContextMenu = false }, + ) { + if (connection.isActive) { + DropdownMenuItem( + text = { + Text( + stringResource(R.string.connection_close), + color = MaterialTheme.colorScheme.error, + ) + }, + leadingIcon = { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + showContextMenu = false + onClose() + }, + ) + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt new file mode 100644 index 0000000000..ee5be77c28 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsScreen.kt @@ -0,0 +1,594 @@ +package io.nekohasekai.sfa.compose.screen.connections + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +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.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +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.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.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compat.LazyColumnCompat +import io.nekohasekai.sfa.compat.rememberOverscrollEffectCompat +import io.nekohasekai.sfa.compose.model.Connection +import io.nekohasekai.sfa.compose.model.ConnectionSort +import io.nekohasekai.sfa.compose.model.ConnectionStateFilter +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier +import io.nekohasekai.sfa.constant.Status + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionsPage( + serviceStatus: Status, + viewModel: ConnectionsViewModel = viewModel(), + asSheet: Boolean = false, + showTitle: Boolean = true, + showTopBar: Boolean = false, + onConnectionClick: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsState() + var showStateMenu by remember { mutableStateOf(false) } + var showSortMenu by remember { mutableStateOf(false) } + var showConnectionsMenu by remember { mutableStateOf(false) } + + if (showTopBar) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_connections)) }, + ) + } + } + + val headerRowModifier = + if (asSheet) { + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + } else { + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + } + + val headerContent: @Composable () -> Unit = { + Row( + modifier = headerRowModifier, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (showTitle) { + Text( + text = stringResource(R.string.title_connections), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + Box { + FilterChip( + selected = uiState.stateFilter != ConnectionStateFilter.Active, + onClick = { showStateMenu = true }, + label = { + Text( + when (uiState.stateFilter) { + ConnectionStateFilter.All -> stringResource(R.string.connection_state_all) + ConnectionStateFilter.Active -> stringResource(R.string.connection_state_active) + ConnectionStateFilter.Closed -> stringResource(R.string.connection_state_closed) + }, + ) + }, + ) + + DropdownMenu( + expanded = showStateMenu, + onDismissRequest = { showStateMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.connection_state_all)) }, + onClick = { + viewModel.setStateFilter(ConnectionStateFilter.All) + showStateMenu = false + }, + leadingIcon = { + if (uiState.stateFilter == ConnectionStateFilter.All) { + Icon(Icons.Default.Check, contentDescription = null) + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.connection_state_active)) }, + onClick = { + viewModel.setStateFilter(ConnectionStateFilter.Active) + showStateMenu = false + }, + leadingIcon = { + if (uiState.stateFilter == ConnectionStateFilter.Active) { + Icon(Icons.Default.Check, contentDescription = null) + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.connection_state_closed)) }, + onClick = { + viewModel.setStateFilter(ConnectionStateFilter.Closed) + showStateMenu = false + }, + leadingIcon = { + if (uiState.stateFilter == ConnectionStateFilter.Closed) { + Icon(Icons.Default.Check, contentDescription = null) + } + }, + ) + } + } + + Box { + IconButton(onClick = { showSortMenu = true }) { + Icon(Icons.Default.SwapVert, contentDescription = null) + } + + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.connection_sort_date)) }, + onClick = { + viewModel.setSort(ConnectionSort.ByDate) + showSortMenu = false + }, + leadingIcon = { + if (uiState.sort == ConnectionSort.ByDate) { + Icon(Icons.Default.Check, contentDescription = null) + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.connection_sort_traffic)) }, + onClick = { + viewModel.setSort(ConnectionSort.ByTraffic) + showSortMenu = false + }, + leadingIcon = { + if (uiState.sort == ConnectionSort.ByTraffic) { + Icon(Icons.Default.Check, contentDescription = null) + } + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.connection_sort_traffic_total)) }, + onClick = { + viewModel.setSort(ConnectionSort.ByTrafficTotal) + showSortMenu = false + }, + leadingIcon = { + if (uiState.sort == ConnectionSort.ByTrafficTotal) { + Icon(Icons.Default.Check, contentDescription = null) + } + }, + ) + } + } + + Box { + IconButton(onClick = { showConnectionsMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + + DropdownMenu( + expanded = showConnectionsMenu, + onDismissRequest = { showConnectionsMenu = false }, + ) { + DropdownMenuItem( + text = { + Text( + if (uiState.isSearchActive) { + stringResource(R.string.close_search) + } else { + stringResource(R.string.search) + }, + ) + }, + onClick = { + viewModel.toggleSearch() + showConnectionsMenu = false + }, + leadingIcon = { + Icon( + imageVector = if (uiState.isSearchActive) Icons.Default.Close else Icons.Default.Search, + contentDescription = null, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.connection_close_all)) }, + onClick = { + viewModel.closeAllConnections() + showConnectionsMenu = false + }, + leadingIcon = { + Icon(Icons.Default.Close, contentDescription = null) + }, + enabled = uiState.connections.any { it.isActive }, + ) + } + } + } + } + + if (asSheet) { + ConnectionsScreen( + serviceStatus = serviceStatus, + viewModel = viewModel, + onConnectionClick = { connection -> onConnectionClick(connection.id) }, + listHeaderContent = headerContent, + asSheet = true, + modifier = modifier.fillMaxSize(), + ) + } else { + Column( + modifier = modifier.fillMaxSize(), + ) { + headerContent() + ConnectionsScreen( + serviceStatus = serviceStatus, + viewModel = viewModel, + onConnectionClick = { connection -> onConnectionClick(connection.id) }, + modifier = Modifier.fillMaxSize(), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionDetailsRoute( + connectionId: String, + serviceStatus: Status, + viewModel: ConnectionsViewModel = viewModel(), + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsState() + val connection = + uiState.allConnections.find { it.id == connectionId } + ?: uiState.connections.find { it.id == connectionId } + var cachedConnection by remember { mutableStateOf(connection) } + if (connection != null) { + cachedConnection = connection + } else if (cachedConnection?.isActive == true) { + cachedConnection = cachedConnection?.copy(closedAt = System.currentTimeMillis()) + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.connection_details)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + actions = { + if (cachedConnection?.isActive == true) { + IconButton(onClick = { viewModel.closeConnection(connectionId) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.connection_close), + ) + } + } + }, + ) + } + + LaunchedEffect(Unit) { + viewModel.setVisible(true) + } + + DisposableEffect(Unit) { + onDispose { + viewModel.setVisible(false) + } + } + + LaunchedEffect(serviceStatus) { + viewModel.updateServiceStatus(serviceStatus) + } + + if (cachedConnection == null) { + LaunchedEffect(connectionId) { + onBack() + } + Box(modifier = modifier.fillMaxSize()) + } else { + ConnectionDetailsScreen( + connection = cachedConnection!!, + onBack = onBack, + onClose = { viewModel.closeConnection(connectionId) }, + modifier = modifier, + showHeader = false, + ) + } +} + +@Composable +fun ConnectionsScreen( + serviceStatus: Status, + viewModel: ConnectionsViewModel = viewModel(), + onConnectionClick: (Connection) -> Unit = {}, + listHeaderContent: (@Composable () -> Unit)? = null, + asSheet: Boolean = false, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.setVisible(true) + } + + DisposableEffect(Unit) { + onDispose { + viewModel.setVisible(false) + } + } + + LaunchedEffect(serviceStatus) { + viewModel.updateServiceStatus(serviceStatus) + } + + val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + + if (asSheet) { + val sheetSwipeToDismissModifier = + rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { + lazyListState.firstVisibleItemIndex == 0 && + lazyListState.firstVisibleItemScrollOffset == 0 + } + LazyColumnCompat( + modifier = + modifier + .fillMaxSize() + .then(sheetSwipeToDismissModifier), + state = lazyListState, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + overscrollEffect = null, + ) { + if (listHeaderContent != null) { + item(key = "connections_list_header") { + listHeaderContent() + } + } + + item(key = "connections_search") { + AnimatedVisibility( + visible = uiState.isSearchActive, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = uiState.searchText, + onValueChange = { viewModel.setSearchText(it) }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.search_connections)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (uiState.searchText.isNotEmpty()) { + IconButton(onClick = { viewModel.setSearchText("") }) { + Icon(Icons.Default.Clear, contentDescription = null) + } + } + }, + singleLine = true, + ) + } + } + + when { + uiState.isLoading -> { + item(key = "connections_loading") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + + uiState.connections.isEmpty() -> { + item(key = "connections_empty") { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 48.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.empty_connections), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + else -> { + items( + items = uiState.connections, + key = { it.id }, + ) { connection -> + ConnectionItem( + connection = connection, + onClick = { onConnectionClick(connection) }, + onClose = { viewModel.closeConnection(connection.id) }, + ) + } + } + } + } + } else { + Column(modifier = modifier.fillMaxSize()) { + AnimatedVisibility( + visible = uiState.isSearchActive, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = uiState.searchText, + onValueChange = { viewModel.setSearchText(it) }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.search_connections)) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (uiState.searchText.isNotEmpty()) { + IconButton(onClick = { viewModel.setSearchText("") }) { + Icon(Icons.Default.Clear, contentDescription = null) + } + } + }, + singleLine = true, + ) + } + + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + uiState.connections.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.empty_connections), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> { + val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState) + LazyColumnCompat( + modifier = + Modifier + .fillMaxSize() + .nestedScroll(bounceBlockingConnection), + state = lazyListState, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + overscrollEffect = rememberOverscrollEffectCompat(), + ) { + items( + items = uiState.connections, + key = { it.id }, + ) { connection -> + ConnectionItem( + connection = connection, + onClick = { onConnectionClick(connection) }, + onClose = { viewModel.closeConnection(connection.id) }, + ) + } + } + } + } + } + } +} + +@Composable +private fun rememberBounceBlockingNestedScrollConnection(lazyListState: LazyListState): NestedScrollConnection = remember(lazyListState) { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = if (available.y < 0) available else Offset.Zero + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity = if (available.y < 0) available else Velocity.Zero + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt new file mode 100644 index 0000000000..c54087c0bf --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/connections/ConnectionsViewModel.kt @@ -0,0 +1,282 @@ +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 +import io.nekohasekai.sfa.compose.model.ConnectionSort +import io.nekohasekai.sfa.compose.model.ConnectionStateFilter +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicLong + +data class ConnectionsUiState( + val connections: List = emptyList(), + val allConnections: List = emptyList(), + val isLoading: Boolean = false, + val stateFilter: ConnectionStateFilter = ConnectionStateFilter.Active, + val sort: ConnectionSort = ConnectionSort.ByDate, + val searchText: String = "", + val isSearchActive: Boolean = false, +) + +sealed class ConnectionsEvent : ScreenEvent { + data class ConnectionClosed(val id: String) : ConnectionsEvent() + data object AllConnectionsClosed : ConnectionsEvent() +} + +class ConnectionsViewModel : + BaseViewModel(), + CommandClient.Handler { + private val commandClient = CommandClient( + viewModelScope, + CommandClient.ConnectionType.Connections, + this, + ) + + private val _serviceStatus = MutableStateFlow(Status.Stopped) + val serviceStatus = _serviceStatus.asStateFlow() + private var lastServiceStatus: Status = Status.Stopped + + private val _visibleCount = MutableStateFlow(0) + + private var connectionsStore: Connections? = null + private val connectionsMutex = Mutex() + private val connectionsGeneration = AtomicLong(0) + + override fun createInitialState() = ConnectionsUiState() + + private data class ConnectionState( + val foreground: Boolean, + val screenOn: Boolean, + val visibleCount: Int, + val status: Status, + ) + + init { + viewModelScope.launch { + combine( + AppLifecycleObserver.isForeground, + AppLifecycleObserver.isScreenOn, + _visibleCount, + _serviceStatus, + ) { foreground, screenOn, visibleCount, status -> + ConnectionState(foreground, screenOn, visibleCount, status) + }.collect { state -> + val shouldConnect = state.foreground && state.screenOn && + state.visibleCount > 0 && state.status == Status.Started + if (shouldConnect) { + updateState { copy(isLoading = true) } + commandClient.connect() + } else { + commandClient.disconnect() + } + } + } + } + + fun setVisible(visible: Boolean) { + _visibleCount.value += if (visible) 1 else -1 + } + + override fun onCleared() { + super.onCleared() + commandClient.disconnect() + } + + private suspend fun handleServiceStatusChange(status: Status) { + if (status != Status.Started) { + withContext(Dispatchers.Default) { + connectionsMutex.withLock { + connectionsStore = null + } + connectionsGeneration.incrementAndGet() + } + updateState { + copy(connections = emptyList(), allConnections = emptyList(), isLoading = false) + } + } + } + + fun updateServiceStatus(status: Status) { + if (status == lastServiceStatus) return + lastServiceStatus = status + viewModelScope.launch { + _serviceStatus.emit(status) + handleServiceStatusChange(status) + } + } + + fun setStateFilter(filter: ConnectionStateFilter) { + updateState { copy(stateFilter = filter) } + requestConnectionsRefresh() + } + + fun setSort(sort: ConnectionSort) { + updateState { copy(sort = sort) } + requestConnectionsRefresh() + } + + fun setSearchText(text: String) { + updateState { copy(searchText = text) } + requestConnectionsRefresh() + } + + fun toggleSearch() { + val newSearchActive = !currentState.isSearchActive + updateState { + copy( + isSearchActive = newSearchActive, + searchText = if (newSearchActive) searchText else "", + ) + } + if (!newSearchActive) { + requestConnectionsRefresh() + } + } + + fun closeConnection(connectionId: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient().closeConnection(connectionId) + withContext(Dispatchers.Main) { + sendEvent(ConnectionsEvent.ConnectionClosed(connectionId)) + } + } catch (e: Exception) { + sendError(e) + } + } + } + + fun closeAllConnections() { + viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient().closeConnections() + withContext(Dispatchers.Main) { + sendEvent(ConnectionsEvent.AllConnectionsClosed) + } + } catch (e: Exception) { + sendError(e) + } + } + } + + override fun onConnected() { + viewModelScope.launch(Dispatchers.Main) { + updateState { copy(isLoading = false) } + } + } + + override fun onDisconnected() { + viewModelScope.launch(Dispatchers.Default) { + connectionsMutex.withLock { + connectionsStore = null + } + connectionsGeneration.incrementAndGet() + withContext(Dispatchers.Main) { + updateState { + copy(connections = emptyList(), allConnections = emptyList(), isLoading = false) + } + } + } + } + + override fun writeConnectionEvents(events: ConnectionEvents) { + viewModelScope.launch(Dispatchers.Default) { + val generation = connectionsGeneration.get() + val snapshot = connectionsMutex.withLock { + if (connectionsStore == null) { + connectionsStore = Connections() + } + val store = connectionsStore ?: return@withLock null + store.applyEvents(events) + buildConnectionLists(store, uiState.value) + } ?: return@launch + if (connectionsGeneration.get() != generation) { + return@launch + } + withContext(Dispatchers.Main) { + if (connectionsGeneration.get() != generation) { + return@withContext + } + updateState { + copy( + connections = snapshot.connections, + allConnections = snapshot.allConnections, + isLoading = false, + ) + } + } + } + } + + private fun requestConnectionsRefresh() { + viewModelScope.launch(Dispatchers.Default) { + val generation = connectionsGeneration.get() + val snapshot = connectionsMutex.withLock { + val store = connectionsStore ?: return@withLock null + buildConnectionLists(store, uiState.value) + } ?: return@launch + if (connectionsGeneration.get() != generation) { + return@launch + } + withContext(Dispatchers.Main) { + if (connectionsGeneration.get() != generation) { + return@withContext + } + updateState { + copy( + connections = snapshot.connections, + allConnections = snapshot.allConnections, + isLoading = false, + ) + } + } + } + } + + private fun buildConnectionLists( + connections: Connections, + currentState: ConnectionsUiState, + ): ConnectionLists { + val allConnectionList = connections.iterator().toList() + .filter { it.outboundType != "dns" } + .map { Connection.from(it) } + + connections.filterState(currentState.stateFilter.libboxValue) + + when (currentState.sort) { + ConnectionSort.ByDate -> connections.sortByDate() + ConnectionSort.ByTraffic -> connections.sortByTraffic() + ConnectionSort.ByTrafficTotal -> connections.sortByTrafficTotal() + } + + val connectionList = connections.iterator().toList() + .filter { it.outboundType != "dns" } + .map { Connection.from(it) } + .filter { it.performSearch(currentState.searchText) } + + return ConnectionLists( + connections = connectionList, + allConnections = allConnectionList, + ) + } + + private data class ConnectionLists( + val connections: List, + val allConnections: List, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt new file mode 100644 index 0000000000..80d0072bb8 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ClashModeCard.kt @@ -0,0 +1,183 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ClashModeCard(modes: List, selectedMode: String, onModeSelected: (String) -> Unit, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.mode), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + ) { + val textMeasurer = rememberTextMeasurer() + val textStyle = MaterialTheme.typography.labelLarge + val density = LocalDensity.current + + val totalTextWidth = remember(modes, textStyle, density) { + modes.sumOf { mode -> + textMeasurer.measure(mode, textStyle).size.width + } + } + val buttonPadding = with(density) { (24.dp * modes.size).roundToPx() } + val estimatedWidth = totalTextWidth + buttonPadding + val availableWidth = with(density) { maxWidth.roundToPx() } + + val useDropdown = estimatedWidth > availableWidth + + if (useDropdown) { + ModeDropdown( + modes = modes, + selectedMode = selectedMode, + onModeSelected = onModeSelected, + ) + } else { + SingleChoiceSegmentedButtonRow( + modifier = Modifier.fillMaxWidth(), + ) { + modes.forEachIndexed { index, mode -> + SegmentedButton( + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = modes.size, + ), + onClick = { onModeSelected(mode) }, + selected = mode == selectedMode, + ) { + Text(mode) + } + } + } + } + } + } + } +} + +@Composable +private fun ModeDropdown(modes: List, selectedMode: String, onModeSelected: (String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + onClick = { expanded = true }, + shape = RoundedCornerShape(12.dp), + color = if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.surfaceDim + }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedMode, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + modes.forEach { mode -> + DropdownMenuItem( + text = { Text(mode) }, + onClick = { + onModeSelected(mode) + expanded = false + }, + leadingIcon = { + if (mode == selectedMode) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt new file mode 100644 index 0000000000..572338b0d3 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ConnectionsCard.kt @@ -0,0 +1,94 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +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.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.material.icons.Icons +import androidx.compose.material.icons.outlined.Cable +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R + +@Composable +fun ConnectionsCard(connectionsIn: String, connectionsOut: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Cable, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.title_connections), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + + // Inbound connections + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.connections_in), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = connectionsIn, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Outbound connections + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.connections_out), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = connectionsOut, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt new file mode 100644 index 0000000000..5c57f20532 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardCardRenderer.kt @@ -0,0 +1,130 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.nekohasekai.sfa.compose.navigation.NewProfileArgs +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.utils.CommandClient + +@Composable +fun DashboardCardRenderer( + cardGroup: CardGroup, + cardWidth: CardWidth, + uiState: DashboardUiState, + serviceStatus: Status = Status.Stopped, + onClashModeSelected: (String) -> Unit, + onSystemProxyToggle: (Boolean) -> Unit, + // Profile card specific props + profiles: List = emptyList(), + selectedProfileId: Long = -1L, + isLoading: Boolean = false, + showAddProfileSheet: Boolean = false, + showProfilePickerSheet: Boolean = false, + updatingProfileId: Long? = null, + updatedProfileId: Long? = null, + onProfileSelected: (Long) -> Unit = {}, + onProfileEdit: (Profile) -> Unit = {}, + onProfileDelete: (Profile) -> Unit = {}, + onProfileShare: (Profile) -> Unit = {}, + onProfileShareURL: (Profile) -> Unit = {}, + onProfileUpdate: (Profile) -> Unit = {}, + onProfileMove: (Int, Int) -> Unit = { _, _ -> }, + onShowAddProfileSheet: () -> Unit = {}, + onHideAddProfileSheet: () -> Unit = {}, + onShowProfilePickerSheet: () -> Unit = {}, + onHideProfilePickerSheet: () -> Unit = {}, + onOpenNewProfile: (NewProfileArgs) -> Unit = {}, + commandClient: CommandClient? = null, + modifier: Modifier = Modifier, +) { + when (cardGroup) { + CardGroup.ClashMode -> { + if (uiState.clashModeVisible) { + ClashModeCard( + modes = uiState.clashModes, + selectedMode = uiState.selectedClashMode, + onModeSelected = onClashModeSelected, + modifier = modifier, + ) + } + } + + CardGroup.UploadTraffic -> { + if (uiState.trafficVisible) { + UploadTrafficCard( + uplink = uiState.uplink, + uplinkTotal = uiState.uplinkTotal, + uplinkHistory = uiState.uplinkHistory, + modifier = modifier, + ) + } + } + + CardGroup.DownloadTraffic -> { + if (uiState.trafficVisible) { + DownloadTrafficCard( + downlink = uiState.downlink, + downlinkTotal = uiState.downlinkTotal, + downlinkHistory = uiState.downlinkHistory, + modifier = modifier, + ) + } + } + + CardGroup.Debug -> { + if (uiState.isStatusVisible) { + DebugCard( + memory = uiState.memory, + goroutines = uiState.goroutines, + modifier = modifier, + ) + } + } + + CardGroup.Connections -> { + if (uiState.trafficVisible) { + ConnectionsCard( + connectionsIn = uiState.connectionsIn, + connectionsOut = uiState.connectionsOut, + modifier = modifier, + ) + } + } + + CardGroup.SystemProxy -> { + if (uiState.systemProxyVisible) { + SystemProxyCard( + enabled = uiState.systemProxyEnabled, + isSwitching = uiState.systemProxySwitching, + onToggle = onSystemProxyToggle, + modifier = modifier, + ) + } + } + + CardGroup.Profiles -> { + ProfilesCard( + profiles = profiles, + selectedProfileId = selectedProfileId, + isLoading = isLoading, + showAddProfileSheet = showAddProfileSheet, + showProfilePickerSheet = showProfilePickerSheet, + updatingProfileId = updatingProfileId, + updatedProfileId = updatedProfileId, + onProfileSelected = onProfileSelected, + onProfileEdit = onProfileEdit, + onProfileDelete = onProfileDelete, + onProfileShare = onProfileShare, + onProfileShareURL = onProfileShareURL, + onProfileUpdate = onProfileUpdate, + onProfileMove = onProfileMove, + onShowAddProfileSheet = onShowAddProfileSheet, + onHideAddProfileSheet = onHideAddProfileSheet, + onShowProfilePickerSheet = onShowProfilePickerSheet, + onHideProfilePickerSheet = onHideProfilePickerSheet, + onOpenNewProfile = onOpenNewProfile, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt new file mode 100644 index 0000000000..b13ea3825d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardScreen.kt @@ -0,0 +1,314 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +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.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.compose.navigation.NewProfileArgs +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import kotlinx.coroutines.launch + +data class CardRenderItem(val cards: List, val isRow: Boolean) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardScreen( + serviceStatus: Status = Status.Stopped, + showStartFab: Boolean = false, + showStatusBar: Boolean = false, + onOpenNewProfile: (NewProfileArgs) -> Unit = {}, + viewModel: DashboardViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsState() + + 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), + ) + } + }, + ) + } + + // Update service status in ViewModel + LaunchedEffect(serviceStatus) { + viewModel.updateServiceStatus(serviceStatus) + } + + // Events are now handled globally in ComposeActivity via GlobalEventBus + + // Show deprecated notes dialog + if (uiState.showDeprecatedDialog && uiState.deprecatedNotes.isNotEmpty()) { + val note = uiState.deprecatedNotes.first() + AlertDialog( + onDismissRequest = { }, + title = { Text(stringResource(R.string.error_deprecated_warning)) }, + text = { Text(note.message) }, + confirmButton = { + TextButton(onClick = { viewModel.dismissDeprecatedNote() }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = + if (!note.migrationLink.isNullOrBlank()) { + { + TextButton(onClick = { + viewModel.sendGlobalEvent(UiEvent.OpenUrl(note.migrationLink)) + viewModel.dismissDeprecatedNote() + }) { + Text(stringResource(R.string.error_deprecated_documentation)) + } + } + } else { + null + }, + ) + } + + val sheetState = rememberModalBottomSheetState() + val scope = rememberCoroutineScope() + val context = LocalContext.current + + // Show dashboard settings bottom sheet + if (uiState.showCardSettingsDialog) { + DashboardSettingsBottomSheet( + sheetState = sheetState, + visibleCards = uiState.visibleCards, + cardOrder = uiState.cardOrder, + onToggleCard = viewModel::toggleCardVisibility, + onReorderCards = viewModel::reorderCards, + onResetOrder = viewModel::resetCardOrder, + onDismiss = { + scope.launch { + sheetState.hide() + viewModel.closeCardSettingsDialog() + } + }, + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + val bottomPadding = when { + showStartFab -> 88.dp + showStatusBar -> 74.dp + else -> 0.dp + } + LazyColumn( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(bottom = bottomPadding), + ) { + // Dynamic dashboard cards + // Show cards when service is running OR if it's the Profiles card (always available) + val serviceRunning = uiState.isStatusVisible + + // Filter cards based on availability + val actuallyVisibleCards = + uiState.visibleCards.filter { cardGroup -> + when (cardGroup) { + CardGroup.Profiles -> true // Profiles card is always available + else -> serviceRunning && isCardAvailableWhenServiceRunning(cardGroup, uiState) + } + }.toSet() + + // Process cards to group half-width cards together + val cardRenderItems = + processCardsForRendering( + cardOrder = uiState.cardOrder, + visibleCards = actuallyVisibleCards, + cardWidths = uiState.cardWidths, + ) + + items(cardRenderItems) { renderItem -> + if (renderItem.isRow && renderItem.cards.size >= 2) { + // Render two half-width cards in a row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + renderItem.cards.forEach { cardGroup -> + DashboardCardRenderer( + cardGroup = cardGroup, + cardWidth = + uiState.cardWidths[cardGroup] + ?: CardWidth.Full, + uiState = uiState, + onClashModeSelected = viewModel::selectClashMode, + onSystemProxyToggle = viewModel::toggleSystemProxy, + // Profile card specific props + profiles = uiState.profiles, + selectedProfileId = uiState.selectedProfileId, + isLoading = uiState.isLoading, + showAddProfileSheet = uiState.showAddProfileSheet, + showProfilePickerSheet = uiState.showProfilePickerSheet, + updatingProfileId = uiState.updatingProfileId, + updatedProfileId = uiState.updatedProfileId, + onProfileSelected = viewModel::selectProfile, + onProfileEdit = viewModel::editProfile, + onProfileDelete = viewModel::deleteProfile, + onProfileShare = viewModel::shareProfile, + onProfileShareURL = viewModel::shareProfileURL, + onProfileUpdate = viewModel::updateProfile, + onProfileMove = viewModel::moveProfile, + onShowAddProfileSheet = viewModel::showAddProfileSheet, + onHideAddProfileSheet = viewModel::hideAddProfileSheet, + onShowProfilePickerSheet = viewModel::showProfilePickerSheet, + onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, + onOpenNewProfile = onOpenNewProfile, + commandClient = viewModel.commandClient, + modifier = + Modifier + .weight(1f) + .fillMaxWidth(), + ) + } + } + } else { + // Render single card (full-width or single half-width) + renderItem.cards.forEach { cardGroup -> + DashboardCardRenderer( + cardGroup = cardGroup, + cardWidth = + uiState.cardWidths[cardGroup] + ?: CardWidth.Full, + uiState = uiState, + serviceStatus = serviceStatus, + onClashModeSelected = viewModel::selectClashMode, + onSystemProxyToggle = viewModel::toggleSystemProxy, + // Profile card specific props + profiles = uiState.profiles, + selectedProfileId = uiState.selectedProfileId, + isLoading = uiState.isLoading, + showAddProfileSheet = uiState.showAddProfileSheet, + showProfilePickerSheet = uiState.showProfilePickerSheet, + updatingProfileId = uiState.updatingProfileId, + updatedProfileId = uiState.updatedProfileId, + onProfileSelected = viewModel::selectProfile, + onProfileEdit = viewModel::editProfile, + onProfileDelete = viewModel::deleteProfile, + onProfileShare = viewModel::shareProfile, + onProfileShareURL = viewModel::shareProfileURL, + onProfileUpdate = viewModel::updateProfile, + onProfileMove = viewModel::moveProfile, + onShowAddProfileSheet = viewModel::showAddProfileSheet, + onHideAddProfileSheet = viewModel::hideAddProfileSheet, + onShowProfilePickerSheet = viewModel::showProfilePickerSheet, + onHideProfilePickerSheet = viewModel::hideProfilePickerSheet, + onOpenNewProfile = onOpenNewProfile, + commandClient = viewModel.commandClient, + ) + } + } + } + } + } +} + +/** + * Process cards for rendering, grouping consecutive half-width cards into rows + */ +fun processCardsForRendering( + cardOrder: List, + visibleCards: Set, + cardWidths: Map, +): List { + val renderItems = mutableListOf() + val visibleOrderedCards = cardOrder.filter { visibleCards.contains(it) } + + var i = 0 + while (i < visibleOrderedCards.size) { + val currentCard = visibleOrderedCards[i] + val currentWidth = cardWidths[currentCard] ?: CardWidth.Full + + if (currentWidth == CardWidth.Half) { + // Check if next card is also half-width + if (i + 1 < visibleOrderedCards.size) { + val nextCard = visibleOrderedCards[i + 1] + val nextWidth = cardWidths[nextCard] ?: CardWidth.Full + + if (nextWidth == CardWidth.Half) { + // Group two half-width cards together + renderItems.add( + CardRenderItem( + cards = listOf(currentCard, nextCard), + isRow = true, + ), + ) + i += 2 + continue + } + } + // Single half-width card + renderItems.add( + CardRenderItem( + cards = listOf(currentCard), + isRow = false, + ), + ) + } else { + // Full-width card + renderItems.add( + CardRenderItem( + cards = listOf(currentCard), + isRow = false, + ), + ) + } + i++ + } + + return renderItems +} + +/** + * Determine if a service-dependent card has data available to display. + * This function is only relevant when the service is running. + * Note: Profiles card is always available and should not use this function. + */ +fun isCardAvailableWhenServiceRunning(cardGroup: CardGroup, uiState: DashboardUiState): Boolean = when (cardGroup) { + CardGroup.ClashMode -> uiState.clashModeVisible + CardGroup.UploadTraffic -> uiState.trafficVisible + CardGroup.DownloadTraffic -> uiState.trafficVisible + CardGroup.Debug -> true // Debug info is always available when service is running + CardGroup.Connections -> uiState.trafficVisible + CardGroup.SystemProxy -> uiState.systemProxyVisible + CardGroup.Profiles -> true // This shouldn't be called for Profiles, but return true for safety +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt new file mode 100644 index 0000000000..12ec111b0a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardSettingsBottomSheet.kt @@ -0,0 +1,437 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.Cable +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material.icons.outlined.Route +import androidx.compose.material.icons.outlined.SettingsEthernet +import androidx.compose.material.icons.outlined.Upload +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compat.animateItemCompat + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashboardSettingsBottomSheet( + sheetState: SheetState, + visibleCards: Set, + cardOrder: List, + onToggleCard: (CardGroup) -> Unit, + onReorderCards: (List) -> Unit, + onResetOrder: () -> Unit, + onDismiss: () -> Unit, +) { + var reorderedList by remember(cardOrder) { mutableStateOf(cardOrder) } + var currentVisibleCards by remember(visibleCards) { mutableStateOf(visibleCards) } + + // Update local state when props change (e.g., after reset) + LaunchedEffect(cardOrder, visibleCards) { + reorderedList = cardOrder + currentVisibleCards = visibleCards + } + + val hapticFeedback = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + // Dragging state + var draggedItem by remember { mutableStateOf(null) } + var draggedIndex by remember { mutableStateOf(-1) } + var dragOffset by remember { mutableStateOf(0f) } + val density = LocalDensity.current + + fun onMove(fromIndex: Int, toIndex: Int) { + if (fromIndex != toIndex && + fromIndex >= 0 && + toIndex >= 0 && + fromIndex < reorderedList.size && + toIndex < reorderedList.size + ) { + val newList = reorderedList.toMutableList() + val item = newList.removeAt(fromIndex) + newList.add(toIndex, item) + reorderedList = newList + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + + ModalBottomSheet( + onDismissRequest = { + if (reorderedList != cardOrder) { + onReorderCards(reorderedList) + } + onDismiss() + }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + dragHandle = { + Surface( + modifier = Modifier.padding(vertical = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + shape = RoundedCornerShape(16.dp), + ) { + Box( + modifier = Modifier.size(width = 48.dp, height = 4.dp), + ) + } + }, + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .fillMaxHeight(0.8f), + ) { + // Header with reset button + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.dashboard_items), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + TextButton( + onClick = { + val defaultOrder = + listOfNotNull( + CardGroup.UploadTraffic, + CardGroup.DownloadTraffic, + CardGroup.Debug, + CardGroup.Connections, + CardGroup.SystemProxy, + CardGroup.ClashMode, + CardGroup.Profiles, + ) + val allCardsEnabled = + setOfNotNull( + CardGroup.ClashMode, + CardGroup.UploadTraffic, + CardGroup.DownloadTraffic, + CardGroup.Debug, + CardGroup.Connections, + CardGroup.SystemProxy, + CardGroup.Profiles, + ) + reorderedList = defaultOrder + currentVisibleCards = allCardsEnabled + onResetOrder() + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + }, + ) { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = stringResource(R.string.reset_order), + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.reset)) + } + } + + // Instruction text + Text( + text = stringResource(R.string.drag_handle_to_reorder), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier + .padding(horizontal = 24.dp) + .padding(bottom = 12.dp), + ) + + // Reorderable list + LazyColumn( + state = listState, + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemsIndexed( + items = reorderedList, + key = { _, item -> item }, + ) { index, cardGroup -> + val isVisible = currentVisibleCards.contains(cardGroup) + val isDragging = draggedIndex == index + + DashboardItemCard( + cardGroup = cardGroup, + isVisible = isVisible, + isDragging = isDragging, + dragOffset = if (isDragging) dragOffset else 0f, + onToggleVisibility = { + currentVisibleCards = + if (currentVisibleCards.contains(cardGroup)) { + currentVisibleCards - cardGroup + } else { + currentVisibleCards + cardGroup + } + onToggleCard(cardGroup) + }, + onDragStart = { + draggedItem = cardGroup + draggedIndex = index + dragOffset = 0f + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDrag = { delta -> + if (draggedIndex == index) { + dragOffset += delta + + // Calculate target index based on drag offset + val itemHeight = with(density) { 80.dp.toPx() } + val threshold = itemHeight * 0.5f + + when { + dragOffset < -threshold && draggedIndex > 0 -> { + // Moving up + onMove(draggedIndex, draggedIndex - 1) + draggedIndex -= 1 + dragOffset += itemHeight + } + + dragOffset > threshold && draggedIndex < reorderedList.size - 1 -> { + // Moving down + onMove(draggedIndex, draggedIndex + 1) + draggedIndex += 1 + dragOffset -= itemHeight + } + } + } + }, + onDragEnd = { + if (reorderedList != cardOrder) { + onReorderCards(reorderedList) + } + draggedItem = null + draggedIndex = -1 + dragOffset = 0f + }, + modifier = + animateItemCompat( + placementSpec = + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + ), + ) + } + } + } + } +} + +@Composable +fun DashboardItemCard( + cardGroup: CardGroup, + isVisible: Boolean, + isDragging: Boolean, + dragOffset: Float, + onToggleVisibility: () -> Unit, + onDragStart: () -> Unit, + onDrag: (Float) -> Unit, + onDragEnd: () -> Unit, + modifier: Modifier = Modifier, +) { + val offsetY = remember { mutableStateOf(0f) } + + LaunchedEffect(dragOffset) { + offsetY.value = dragOffset + } + + val cardElevation by animateDpAsState( + targetValue = if (isDragging) 6.dp else 1.dp, + animationSpec = tween(durationMillis = 300), + label = "elevation", + ) + + Card( + modifier = + modifier + .fillMaxWidth() + .offset(y = with(LocalDensity.current) { offsetY.value.toDp() }) + .zIndex(if (isDragging) 1f else 0f) + .clip(RoundedCornerShape(12.dp)), + elevation = + CardDefaults.cardElevation( + defaultElevation = cardElevation, + ), + colors = + CardDefaults.cardColors( + containerColor = + if (isDragging) { + MaterialTheme.colorScheme.surface.copy(alpha = 0.95f) + } else { + MaterialTheme.colorScheme.surface + }, + ), + border = + BorderStroke( + width = 1.dp, + color = + if (isVisible) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Drag handle + val draggableState = + rememberDraggableState { delta -> + onDrag(delta) + } + + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = stringResource(R.string.drag_to_reorder), + modifier = + Modifier + .size(24.dp) + .draggable( + state = draggableState, + orientation = Orientation.Vertical, + onDragStarted = { onDragStart() }, + onDragStopped = { onDragEnd() }, + ) + .padding(4.dp), + tint = + if (isDragging) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + // Card icon + Icon( + imageVector = + when (cardGroup) { + CardGroup.Debug -> Icons.Outlined.BugReport + CardGroup.Connections -> Icons.Outlined.Cable + CardGroup.UploadTraffic -> Icons.Outlined.Upload + CardGroup.DownloadTraffic -> Icons.Outlined.Download + CardGroup.ClashMode -> Icons.Outlined.Route + CardGroup.SystemProxy -> Icons.Outlined.SettingsEthernet + CardGroup.Profiles -> Icons.Outlined.Person + }, + contentDescription = null, + modifier = + Modifier + .size(24.dp) + .padding(horizontal = 4.dp), + tint = + if (isVisible) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + // Card info + Column( + modifier = + Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) { + Text( + text = + when (cardGroup) { + CardGroup.Debug -> stringResource(R.string.title_debug) + CardGroup.Connections -> stringResource(R.string.title_connections) + CardGroup.UploadTraffic -> stringResource(R.string.upload) + CardGroup.DownloadTraffic -> stringResource(R.string.download) + CardGroup.ClashMode -> stringResource(R.string.clash_mode) + CardGroup.SystemProxy -> stringResource(R.string.system_proxy) + CardGroup.Profiles -> stringResource(R.string.title_configuration) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // Visibility toggle - Profiles card cannot be disabled + Switch( + checked = isVisible, + onCheckedChange = { onToggleVisibility() }, + enabled = cardGroup != CardGroup.Profiles, // Disable switch for Profiles card + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000000..943eca0595 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DashboardViewModel.kt @@ -0,0 +1,766 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.OutboundGroup +import io.nekohasekai.libbox.StatusMessage +import io.nekohasekai.sfa.bg.BoxService +import io.nekohasekai.sfa.compose.base.BaseViewModel +import io.nekohasekai.sfa.compose.base.UiEvent +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +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.HTTPClient +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.launch +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONException +import java.io.File +import java.util.Collections +import java.util.Date + +enum class CardGroup { + ClashMode, + UploadTraffic, + DownloadTraffic, + Debug, + Connections, + SystemProxy, + Profiles, +} + +enum class CardWidth { + Half, + Full, +} + +data class DashboardUiState( + val serviceStatus: Status = Status.Stopped, + val profiles: List = emptyList(), + val selectedProfileId: Long = -1L, + val selectedProfileName: String? = null, + val isLoading: Boolean = false, + val hasGroups: Boolean = false, + val groupsCount: Int = 0, + val connectionsCount: Int = 0, + val serviceStartTime: Long? = null, + val deprecatedNotes: List = emptyList(), + val showDeprecatedDialog: Boolean = false, + val showAddProfileSheet: Boolean = false, + val showProfilePickerSheet: Boolean = false, + val updatingProfileId: Long? = null, + val updatedProfileId: Long? = null, + // Status + val memory: String = "", + val goroutines: String = "", + val isStatusVisible: Boolean = false, + // Traffic + val trafficVisible: Boolean = false, + val connectionsIn: String = "0", + val connectionsOut: String = "0", + val uplink: String = "0 B/s", + val downlink: String = "0 B/s", + val uplinkTotal: String = "0 B", + val downlinkTotal: String = "0 B", + val uplinkHistory: List = List(30) { 0f }, + val downlinkHistory: List = List(30) { 0f }, + // Clash Mode + val clashModeVisible: Boolean = false, + val clashModes: List = emptyList(), + val selectedClashMode: String = "", + // System Proxy + val systemProxyVisible: Boolean = false, + val systemProxyEnabled: Boolean = false, + val systemProxySwitching: Boolean = false, + // Card visibility settings + val visibleCards: Set = + setOf( + CardGroup.ClashMode, + CardGroup.UploadTraffic, + CardGroup.DownloadTraffic, + CardGroup.Debug, + CardGroup.Connections, + CardGroup.SystemProxy, + CardGroup.Profiles, + ), + val cardOrder: List = + listOf( + CardGroup.UploadTraffic, + CardGroup.DownloadTraffic, + CardGroup.Debug, + CardGroup.Connections, + CardGroup.SystemProxy, + CardGroup.ClashMode, + CardGroup.Profiles, + ), + val cardWidths: Map = + mapOf( + CardGroup.ClashMode to CardWidth.Full, + CardGroup.UploadTraffic to CardWidth.Half, + CardGroup.DownloadTraffic to CardWidth.Half, + CardGroup.Debug to CardWidth.Half, + CardGroup.Connections to CardWidth.Half, + CardGroup.SystemProxy to CardWidth.Full, + CardGroup.Profiles to CardWidth.Full, + ), + val showCardSettingsDialog: Boolean = false, +) { + data class DeprecatedNote(val message: String, val migrationLink: String?) +} + +// DashboardViewModel now only uses UiEvent for all events +// No need for DashboardEvent anymore as all events are handled globally + +class DashboardViewModel : + BaseViewModel(), + CommandClient.Handler { + private val _serviceStatus = MutableStateFlow(Status.Stopped) + val serviceStatus: StateFlow = _serviceStatus.asStateFlow() + + internal val commandClient = + CommandClient( + viewModelScope, + listOf( + CommandClient.ConnectionType.Status, + CommandClient.ConnectionType.ClashMode, + CommandClient.ConnectionType.Groups, + ), + this, + ) + + override fun createInitialState(): DashboardUiState { + val savedOrder = loadItemOrder() + val disabledItems = loadDisabledItems() + + // Calculate visible items (all items minus disabled) + val allItems = CardGroup.values().toSet() + val visibleCards = allItems - disabledItems + + return DashboardUiState( + cardOrder = savedOrder, + visibleCards = visibleCards, + ) + } + + init { + loadProfiles() + ProfileManager.registerCallback(::onProfilesChanged) + + viewModelScope.launch { + AppLifecycleObserver.isForeground.collect { foreground -> + if (_serviceStatus.value != Status.Started) return@collect + if (foreground) { + commandClient.connect() + } else { + commandClient.disconnect() + } + } + } + } + + override fun onCleared() { + super.onCleared() + ProfileManager.unregisterCallback(::onProfilesChanged) + commandClient.disconnect() + } + + private fun onProfilesChanged() { + loadProfiles() + } + + private fun loadProfiles() { + viewModelScope.launch(Dispatchers.IO) { + try { + val profiles = ProfileManager.list() + val selectedId = Settings.selectedProfile + + withContext(Dispatchers.Main) { + updateState { + copy( + profiles = profiles, + selectedProfileId = selectedId, + selectedProfileName = profiles.find { it.id == selectedId }?.name, + ) + } + } + } catch (e: Exception) { + sendError(e) + } + } + } + + private fun checkDeprecatedNotes() { + viewModelScope.launch(Dispatchers.IO) { + try { + // Check if deprecated warnings are disabled + if (Settings.disableDeprecatedWarnings) { + return@launch + } + + val notes = Libbox.newStandaloneCommandClient().deprecatedNotes + if (notes.hasNext()) { + val notesList = mutableListOf() + while (notes.hasNext()) { + val note = notes.next() + notesList.add( + DashboardUiState.DeprecatedNote( + message = note.message(), + migrationLink = note.migrationLink, + ), + ) + } + withContext(Dispatchers.Main) { + updateState { + copy( + deprecatedNotes = notesList, + showDeprecatedDialog = notesList.isNotEmpty(), + ) + } + } + } + } catch (e: Exception) { + sendError(e) + } + } + } + + fun toggleService() { + when (currentState.serviceStatus) { + Status.Starting, Status.Started -> stopService() + Status.Stopped -> sendGlobalEvent(UiEvent.RequestStartService) + else -> { /* Ignore while transitioning */ } + } + } + + private fun stopService() { + viewModelScope.launch(Dispatchers.IO) { + try { + BoxService.stop() + // Status will be updated via updateServiceStatus callback + } catch (e: Exception) { + sendError(e) + } + } + } + + fun dismissDeprecatedNote() { + val notes = currentState.deprecatedNotes + if (notes.isNotEmpty()) { + updateState { + copy( + deprecatedNotes = notes.drop(1), + showDeprecatedDialog = notes.size > 1, + ) + } + } + } + + fun selectProfile(profileId: Long) { + if (currentState.isLoading) return + + viewModelScope.launch(Dispatchers.IO) { + try { + updateState { copy(isLoading = true) } + val profile = ProfileManager.get(profileId) ?: return@launch + + Settings.selectedProfile = profileId + + // Check if service is running + if (_serviceStatus.value == Status.Started) { + val restart = Settings.rebuildServiceMode() + if (restart) { + // Need full restart + BoxService.stop() + sendGlobalEvent(UiEvent.RequestReconnectService) + for (i in 0 until 30) { + if (_serviceStatus.value == Status.Stopped) { + break + } + delay(100L) + } + sendGlobalEvent(UiEvent.RequestStartService) + } else { + // Just reload + Libbox.newStandaloneCommandClient().serviceReload() + } + } + + withContext(Dispatchers.Main) { + loadProfiles() + } + } catch (e: Exception) { + sendError(e) + } finally { + updateState { copy(isLoading = false) } + } + } + } + + fun editProfile(profile: Profile) { + sendGlobalEvent(UiEvent.EditProfile(profile.id)) + } + + fun deleteProfile(profile: Profile) { + viewModelScope.launch(Dispatchers.IO) { + try { + // Update UI immediately for responsiveness + withContext(Dispatchers.Main) { + updateState { + copy( + profiles = profiles.filter { p -> p.id != profile.id }, + ) + } + } + // Then delete from database + ProfileManager.delete(profile) + } catch (e: Exception) { + // Reload profiles if deletion fails + loadProfiles() + sendError(e) + } + } + } + + fun shareProfile(profile: Profile) { + // Handled directly in ProfilesCard + } + + fun shareProfileURL(profile: Profile) { + // Handled directly in ProfilesCard + } + + fun updateProfile(profile: Profile) { + if (profile.typed.type != TypedProfile.Type.Remote) return + + viewModelScope.launch(Dispatchers.IO) { + // Set updating state + withContext(Dispatchers.Main) { + updateState { copy(updatingProfileId = profile.id) } + } + + try { + // Fetch remote config + val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } + Libbox.checkConfig(content) + + // Check if content changed + val file = File(profile.typed.path) + var contentChanged = false + if (!file.exists() || file.readText() != content) { + file.writeText(content) + contentChanged = true + } + + // Update last updated time + profile.typed.lastUpdated = Date() + ProfileManager.update(profile) + + // Reload profiles + loadProfiles() + + // Show success state + withContext(Dispatchers.Main) { + updateState { copy(updatingProfileId = null, updatedProfileId = profile.id) } + } + + // Clear success state after delay + withContext(Dispatchers.Main) { + delay(1500) + updateState { copy(updatedProfileId = null) } + } + + // Restart service if this is the selected profile and content changed + if (contentChanged && profile.id == Settings.selectedProfile) { + withContext(Dispatchers.Main) { + sendGlobalEvent(UiEvent.RequestReconnectService) + } + } + } catch (e: Exception) { + sendErrorMessage("Failed to update profile: ${e.message}") + // Clear updating state on error + withContext(Dispatchers.Main) { + updateState { copy(updatingProfileId = null) } + } + } + } + } + + fun moveProfile(from: Int, to: Int) { + val currentProfiles = currentState.profiles.toMutableList() + + if (from < to) { + for (i in from until to) { + Collections.swap(currentProfiles, i, i + 1) + } + } else { + for (i in from downTo to + 1) { + Collections.swap(currentProfiles, i, i - 1) + } + } + + // Update UI immediately + updateState { copy(profiles = currentProfiles) } + + // Update user order in database + viewModelScope.launch(Dispatchers.IO) { + currentProfiles.forEachIndexed { index, profile -> + profile.userOrder = index.toLong() + } + ProfileManager.update(currentProfiles) + } + } + + fun showAddProfileSheet() { + updateState { copy(showAddProfileSheet = true) } + } + + fun hideAddProfileSheet() { + updateState { copy(showAddProfileSheet = false) } + } + + fun showProfilePickerSheet() { + updateState { copy(showProfilePickerSheet = true) } + } + + fun hideProfilePickerSheet() { + updateState { copy(showProfilePickerSheet = false) } + } + + fun updateServiceStatus(status: Status) { + viewModelScope.launch { + _serviceStatus.emit(status) + updateState { + copy( + serviceStatus = status, + isStatusVisible = status == Status.Starting || status == Status.Started, + ) + } + handleServiceStatusChange(status) + } + } + + private fun handleServiceStatusChange(status: Status) { + when (status) { + Status.Started -> { + checkDeprecatedNotes() + if (AppLifecycleObserver.isForeground.value) { + commandClient.connect() + } + reloadSystemProxyStatus() + reloadStartedAt() + } + + Status.Stopped -> { + commandClient.disconnect() + updateState { + copy( + hasGroups = false, + groupsCount = 0, + connectionsCount = 0, + serviceStartTime = null, + clashModeVisible = false, + systemProxyVisible = false, + trafficVisible = false, + memory = "", + goroutines = "", + connectionsIn = "0", + connectionsOut = "0", + uplink = "0 B/s", + downlink = "0 B/s", + uplinkTotal = "0 B", + downlinkTotal = "0 B", + uplinkHistory = List(30) { 0f }, + downlinkHistory = List(30) { 0f }, + ) + } + } + + else -> {} + } + } + + private fun reloadStartedAt() { + viewModelScope.launch(Dispatchers.IO) { + try { + val startedAt = Libbox.newStandaloneCommandClient().startedAt + withContext(Dispatchers.Main) { + updateState { + copy(serviceStartTime = startedAt) + } + } + } catch (_: Exception) { + } + } + } + + private fun reloadSystemProxyStatus() { + viewModelScope.launch(Dispatchers.IO) { + try { + val status = Libbox.newStandaloneCommandClient().systemProxyStatus + withContext(Dispatchers.Main) { + updateState { + copy( + systemProxyVisible = status.available, + systemProxyEnabled = status.enabled, + ) + } + } + } catch (e: Exception) { + // Ignore errors + } + } + } + + fun toggleSystemProxy(enabled: Boolean) { + if (currentState.systemProxySwitching) return + + viewModelScope.launch(Dispatchers.IO) { + try { + updateState { copy(systemProxySwitching = true) } + Settings.systemProxyEnabled = enabled + Libbox.newStandaloneCommandClient().setSystemProxyEnabled(enabled) + delay(1000L) + withContext(Dispatchers.Main) { + updateState { + copy( + systemProxyEnabled = enabled, + systemProxySwitching = false, + ) + } + } + } catch (e: Exception) { + sendError(e) + updateState { copy(systemProxySwitching = false) } + } + } + } + + fun selectClashMode(mode: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient().setClashMode(mode) + // Update UI state directly without reconnecting + withContext(Dispatchers.Main) { + updateState { + copy(selectedClashMode = mode) + } + } + } catch (e: Exception) { + sendError(e) + } + } + } + + // CommandClient.Handler implementation + override fun onConnected() { + viewModelScope.launch(Dispatchers.Main) { + updateState { copy(isStatusVisible = true) } + } + } + + override fun onDisconnected() { + viewModelScope.launch(Dispatchers.Main) { + updateState { + copy( + memory = "", + goroutines = "", + isStatusVisible = false, + ) + } + } + } + + override fun updateStatus(status: StatusMessage) { + viewModelScope.launch(Dispatchers.Main) { + updateState { + // Update history by adding new values and removing old ones + val newUplinkHistory = (uplinkHistory.drop(1) + status.uplink.toFloat()) + val newDownlinkHistory = (downlinkHistory.drop(1) + status.downlink.toFloat()) + + // Format the total values + val newUplinkTotal = Libbox.formatBytes(status.uplinkTotal) + val newDownlinkTotal = Libbox.formatBytes(status.downlinkTotal) + + copy( + memory = Libbox.formatBytes(status.memory), + goroutines = status.goroutines.toString(), + // Only set trafficVisible to true, never back to false from status updates + trafficVisible = if (status.trafficAvailable) true else trafficVisible, + connectionsCount = status.connectionsIn, + connectionsIn = status.connectionsIn.toString(), + connectionsOut = status.connectionsOut.toString(), + uplink = "${Libbox.formatBytes(status.uplink)}/s", + downlink = "${Libbox.formatBytes(status.downlink)}/s", + // Only update total values if they've actually changed + uplinkTotal = if (newUplinkTotal != uplinkTotal) newUplinkTotal else uplinkTotal, + downlinkTotal = if (newDownlinkTotal != downlinkTotal) newDownlinkTotal else downlinkTotal, + uplinkHistory = newUplinkHistory, + downlinkHistory = newDownlinkHistory, + ) + } + } + } + + override fun initializeClashMode(modeList: List, currentMode: String) { + viewModelScope.launch(Dispatchers.Main) { + updateState { + copy( + clashModeVisible = modeList.size > 1, + clashModes = modeList, + selectedClashMode = currentMode, + ) + } + } + } + + override fun updateClashMode(newMode: String) { + viewModelScope.launch(Dispatchers.Main) { + updateState { + copy(selectedClashMode = newMode) + } + } + } + + override fun updateGroups(newGroups: MutableList) { + viewModelScope.launch(Dispatchers.Main) { + val hasGroups = newGroups.isNotEmpty() + updateState { + copy(hasGroups = hasGroups, groupsCount = newGroups.size) + } + } + } + + fun toggleCardSettingsDialog() { + updateState { + copy(showCardSettingsDialog = !showCardSettingsDialog) + } + } + + fun toggleCardVisibility(cardGroup: CardGroup) { + // Profiles card cannot be disabled + if (cardGroup == CardGroup.Profiles) { + return + } + + updateState { + val newVisibleCards = + if (visibleCards.contains(cardGroup)) { + visibleCards - cardGroup + } else { + visibleCards + cardGroup + } + // Save disabled items to settings + saveDisabledItems(newVisibleCards) + // Also save the current order if not already saved (indicates user has configured dashboard) + if (Settings.dashboardItemOrder.isBlank()) { + saveItemOrder(cardOrder) + } + copy(visibleCards = newVisibleCards) + } + } + + fun closeCardSettingsDialog() { + updateState { + copy(showCardSettingsDialog = false) + } + } + + fun reorderCards(newOrder: List) { + updateState { + saveItemOrder(newOrder) + copy(cardOrder = newOrder) + } + } + + fun resetCardOrder() { + // Clear saved settings to restore defaults + Settings.dashboardItemOrder = "" + Settings.dashboardDisabledItems = emptySet() + + updateState { + copy( + cardOrder = getDefaultItemOrder(), + visibleCards = CardGroup.values().toSet(), + ) + } + } + + // Helper functions for serialization + private fun getDefaultItemOrder() = listOf( + CardGroup.UploadTraffic, + CardGroup.DownloadTraffic, + CardGroup.Debug, + CardGroup.Connections, + CardGroup.SystemProxy, + CardGroup.ClashMode, + CardGroup.Profiles, + ) + + private fun loadItemOrder(): List { + val savedOrder = Settings.dashboardItemOrder + if (savedOrder.isBlank()) { + return getDefaultItemOrder() + } + + return try { + val jsonArray = JSONArray(savedOrder) + val order = mutableListOf() + + for (i in 0 until jsonArray.length()) { + val itemName = jsonArray.getString(i) + stringToCardGroup(itemName)?.let { order.add(it) } + } + + // Add any new items that aren't in the saved order + val allItems = CardGroup.values().toSet() + val savedItems = order.toSet() + val newItems = allItems - savedItems + + order.addAll(newItems) + order + } catch (e: JSONException) { + getDefaultItemOrder() + } + } + + private fun saveItemOrder(order: List) { + val jsonArray = JSONArray() + order.forEach { item -> + jsonArray.put(cardGroupToString(item)) + } + Settings.dashboardItemOrder = jsonArray.toString() + } + + private fun loadDisabledItems(): Set { + val savedDisabled = Settings.dashboardDisabledItems + // Filter out Profiles from disabled items (it cannot be disabled) + return savedDisabled.mapNotNull { stringToCardGroup(it) } + .filter { it != CardGroup.Profiles } + .toSet() + } + + private fun saveDisabledItems(visibleCards: Set) { + val allItems = CardGroup.values().toSet() + // Always ensure Profiles is in visibleCards (cannot be disabled) + val actualVisibleCards = visibleCards + CardGroup.Profiles + val disabledItems = allItems - actualVisibleCards + Settings.dashboardDisabledItems = disabledItems.map { cardGroupToString(it) }.toSet() + } + + private fun cardGroupToString(card: CardGroup): String = card.name + + private fun stringToCardGroup(name: String): CardGroup? = try { + CardGroup.valueOf(name) + } catch (e: IllegalArgumentException) { + null + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt new file mode 100644 index 0000000000..817bd97e12 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DebugCard.kt @@ -0,0 +1,94 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +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.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.material.icons.Icons +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R + +@Composable +fun DebugCard(memory: String, goroutines: String, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.BugReport, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.title_debug), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + + // Memory item + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.memory), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = memory.ifEmpty { stringResource(R.string.loading) }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Goroutines item + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.goroutines), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = goroutines.ifEmpty { stringResource(R.string.loading) }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt new file mode 100644 index 0000000000..5f86056854 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/DownloadTrafficCard.kt @@ -0,0 +1,79 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.LineChart + +@Composable +fun DownloadTrafficCard(downlink: String, downlinkTotal: String, downlinkHistory: List, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.download), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = downlink, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + text = downlinkTotal, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LineChart( + data = downlinkHistory, + lineColor = MaterialTheme.colorScheme.primary, + animate = false, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt new file mode 100644 index 0000000000..741f8a1af2 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/GroupsCard.kt @@ -0,0 +1,660 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.UnfoldLess +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +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.SnackbarHostState +import androidx.compose.material3.Surface +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.key +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compat.LazyColumnCompat +import io.nekohasekai.sfa.compat.rememberOverscrollEffectCompat +import io.nekohasekai.sfa.compose.model.Group +import io.nekohasekai.sfa.compose.model.GroupItem +import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.CommandClient + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GroupsCard( + serviceStatus: Status, + commandClient: CommandClient? = null, + viewModel: GroupsViewModel? = null, + showTopBar: Boolean = false, + listHeaderContent: (@Composable () -> Unit)? = null, + asSheet: Boolean = false, + modifier: Modifier = Modifier, +) { + val actualViewModel: GroupsViewModel = viewModel ?: viewModel( + factory = + object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + @Suppress("UNCHECKED_CAST") + return GroupsViewModel(commandClient) as T + } + }, + ) + val snackbarHostState = remember { SnackbarHostState() } + val uiState by actualViewModel.uiState.collectAsState() + + if (showTopBar) { + val allCollapsed = uiState.expandedGroups.isEmpty() + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_groups)) }, + actions = { + if (uiState.groups.isNotEmpty()) { + IconButton(onClick = { actualViewModel.toggleAllGroups() }) { + Icon( + imageVector = + if (allCollapsed) { + Icons.Default.UnfoldMore + } else { + Icons.Default.UnfoldLess + }, + contentDescription = + if (allCollapsed) { + stringResource(R.string.expand_all) + } else { + stringResource(R.string.collapse_all) + }, + ) + } + } + }, + ) + } + } + + // Stable callbacks to prevent recomposition - use remember with viewModel as key + val onToggleExpanded = + remember(actualViewModel) { + { groupTag: String -> actualViewModel.toggleGroupExpand(groupTag) } + } + val onItemSelected = + remember(actualViewModel) { + { groupTag: String, itemTag: String -> actualViewModel.selectGroupItem(groupTag, itemTag) } + } + val onUrlTest = + remember(actualViewModel) { + { groupTag: String -> actualViewModel.urlTest(groupTag) } + } + + // Only update service status when it actually changes + LaunchedEffect(serviceStatus) { + actualViewModel.updateServiceStatus(serviceStatus) + } + + // Show snackbar when needed + LaunchedEffect(uiState.showCloseConnectionsSnackbar) { + if (uiState.showCloseConnectionsSnackbar) { + val result = + snackbarHostState.showSnackbar( + message = "Close all connections?", + actionLabel = "Close", + duration = androidx.compose.material3.SnackbarDuration.Indefinite, + withDismissAction = true, + ) + when (result) { + androidx.compose.material3.SnackbarResult.ActionPerformed -> { + actualViewModel.closeConnections() + } + + androidx.compose.material3.SnackbarResult.Dismissed -> { + actualViewModel.dismissCloseConnectionsSnackbar() + } + } + } + } + + GroupsCardContent( + uiState = uiState, + onToggleExpanded = onToggleExpanded, + onItemSelected = onItemSelected, + onUrlTest = onUrlTest, + listHeaderContent = listHeaderContent, + asSheet = asSheet, + modifier = modifier, + ) +} + +@Composable +private fun GroupsCardContent( + uiState: io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsUiState, + onToggleExpanded: (String) -> Unit, + onItemSelected: (String, String) -> Unit, + onUrlTest: (String) -> Unit, + listHeaderContent: (@Composable () -> Unit)? = null, + asSheet: Boolean = false, + modifier: Modifier = Modifier, +) { + val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + val scrollModifier = + if (asSheet) { + rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier { + lazyListState.firstVisibleItemIndex == 0 && + lazyListState.firstVisibleItemScrollOffset == 0 + } + } else { + Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(lazyListState)) + } + val overscrollEffect = if (asSheet) null else rememberOverscrollEffectCompat() + + LazyColumnCompat( + modifier = + modifier + .fillMaxSize() + .then(scrollModifier), + state = lazyListState, + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + overscrollEffect = overscrollEffect, + ) { + if (listHeaderContent != null) { + item(key = "groups_list_header") { + listHeaderContent() + } + } + + when { + uiState.isLoading -> { + item(key = "groups_loading") { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + + uiState.groups.isEmpty() -> { + item(key = "groups_empty") { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(100.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No groups available", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + else -> { + items( + items = uiState.groups, + key = { it.tag }, + contentType = { "GroupCard" }, + ) { group -> + ProxyGroupItem( + group = group, + isExpanded = uiState.expandedGroups.contains(group.tag), + onToggleExpanded = { onToggleExpanded(group.tag) }, + onItemSelected = { itemTag -> onItemSelected(group.tag, itemTag) }, + onUrlTest = { onUrlTest(group.tag) }, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProxyGroupItem( + group: Group, + isExpanded: Boolean, + onToggleExpanded: () -> Unit, + onItemSelected: (String) -> Unit, + onUrlTest: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + // Header (clickable to expand/collapse) + Surface( + onClick = onToggleExpanded, + color = Color.Transparent, + ) { + ListItem( + headlineContent = { + Column { + Text( + text = group.tag, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = Libbox.proxyDisplayType(group.type), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Show selected item when collapsed + AnimatedVisibility( + visible = !isExpanded && group.selected.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "•", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = group.selected, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + }, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // URL Test button + AnimatedVisibility( + visible = group.selectable, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + IconButton( + onClick = { + onUrlTest() + }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Speed, + contentDescription = stringResource(R.string.url_test), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Expand/Collapse indicator + val rotationAngle by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + animationSpec = tween(300), + label = "ExpandIcon", + ) + + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + modifier = + Modifier + .size(24.dp) + .graphicsLayer { rotationZ = rotationAngle }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + // Expandable content + AnimatedVisibility( + visible = isExpanded && group.items.isNotEmpty(), + enter = + expandVertically(animationSpec = tween(300)) + + fadeIn( + animationSpec = + tween( + 300, + ), + ), + exit = + shrinkVertically(animationSpec = tween(300)) + + fadeOut( + animationSpec = + tween( + 300, + ), + ), + ) { + Column { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + thickness = 1.dp, + ) + + // Proxy Items + ProxyItemsList( + items = group.items, + selectedTag = group.selected, + isSelectable = group.selectable, + onItemSelected = onItemSelected, + ) + } + } + } + } +} + +@Composable +private fun ProxyItemsList(items: List, selectedTag: String, isSelectable: Boolean, onItemSelected: (String) -> Unit) { + val itemsPerRow = 2 + val chunkedItems = + remember(items) { + items.chunked(itemsPerRow) + } + + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + chunkedItems.forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowItems.forEach { item -> + key(item.tag) { + Box( + modifier = Modifier.weight(1f), + ) { + ProxyChip( + item = item, + isSelected = item.tag == selectedTag, + isSelectable = isSelectable, + onClick = { onItemSelected(item.tag) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + repeat(itemsPerRow - rowItems.size) { + Box(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProxyChip(item: GroupItem, isSelected: Boolean, isSelectable: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + // Use simpler, faster animations + val animatedElevation by animateFloatAsState( + targetValue = if (isSelected) 6.dp.value else 1.dp.value, + animationSpec = tween(150), + label = "Elevation", + ) + + val surfaceModifier = modifier + val surfaceShape = RoundedCornerShape(8.dp) + val surfaceColor = + when { + isSelected -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.surface + } + val surfaceBorder = + androidx.compose.foundation.BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = + when { + isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + }, + ) + + val content: @Composable () -> Unit = { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // First line: Name + Text( + text = item.tag, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // Second line: Type on left, Latency on right + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Type + Text( + text = Libbox.proxyDisplayType(item.type), + style = MaterialTheme.typography.labelSmall, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + }, + ) + + // Latency + AnimatedVisibility( + visible = item.urlTestTime > 0, + enter = fadeIn(), + exit = fadeOut(), + ) { + ProxyLatencyBadge( + delay = item.urlTestDelay, + isSelected = isSelected, + ) + } + } + } + } + } + + if (isSelectable) { + Surface( + onClick = onClick, + modifier = surfaceModifier, + shape = surfaceShape, + color = surfaceColor, + tonalElevation = animatedElevation.dp, + border = surfaceBorder, + content = content, + ) + } else { + Surface( + modifier = surfaceModifier, + shape = surfaceShape, + color = surfaceColor, + tonalElevation = animatedElevation.dp, + border = surfaceBorder, + content = content, + ) + } +} + +@Composable +private fun ProxyLatencyBadge(delay: Int, isSelected: Boolean, modifier: Modifier = Modifier) { + // Direct color calculation without animation for better performance + val colorScheme = MaterialTheme.colorScheme + val latencyColor = + remember(delay, isSelected) { + when { + delay < 100 -> { + // Excellent - green/tertiary + if (isSelected) { + colorScheme.tertiary + } else { + colorScheme.tertiary.copy(alpha = 0.9f) + } + } + + delay < 300 -> { + // Good - primary + if (isSelected) { + colorScheme.primary + } else { + colorScheme.primary.copy(alpha = 0.9f) + } + } + + delay < 500 -> { + // Fair - secondary/warning + if (isSelected) { + colorScheme.secondary + } else { + colorScheme.secondary.copy(alpha = 0.9f) + } + } + + else -> { + // Poor - error + if (isSelected) { + colorScheme.error + } else { + colorScheme.error.copy(alpha = 0.9f) + } + } + } + } + + Text( + text = "${delay}ms", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = latencyColor, + modifier = modifier, + ) +} + +@Composable +private fun rememberBounceBlockingNestedScrollConnection(lazyListState: LazyListState): NestedScrollConnection = remember(lazyListState) { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + // Only block upward scroll (y < 0) at bottom to prevent sheet expansion + // Allow downward scroll (y > 0) at top to let sheet collapse + return if (available.y < 0) available else Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + // Only block upward fling (y < 0) to prevent sheet expansion + // Allow downward fling (y > 0) to let sheet collapse + return if (available.y < 0) available else Velocity.Zero + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt new file mode 100644 index 0000000000..c4a863145a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilePickerSheet.kt @@ -0,0 +1,490 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.IosShare +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.filled.Save +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.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.ui.graphics.lerp +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.ProfileContent +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog +import io.nekohasekai.sfa.compose.util.ProfileIcons +import io.nekohasekai.sfa.compose.util.QRCodeGenerator +import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.ktx.shareProfile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyListState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ProfilePickerSheet( + profiles: List, + selectedProfileId: Long, + onProfileSelected: (Profile) -> Unit, + onProfileEdit: (Profile) -> Unit, + onProfileDelete: (Profile) -> Unit, + onProfileMove: (Int, Int) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + var showQRCodeDialog by remember { mutableStateOf(false) } + var qrCodeProfile by remember { mutableStateOf(null) } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.title_configuration), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding( + start = 24.dp, + end = 24.dp, + top = 8.dp, + bottom = 16.dp, + ), + ) + + val lazyListState = rememberLazyListState() + val reorderableLazyListState = + rememberReorderableLazyListState(lazyListState) { from, to -> + onProfileMove(from.index, to.index) + } + + LazyColumn( + state = lazyListState, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 100.dp, max = 400.dp) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + itemsIndexed(profiles, key = { _, profile -> profile.id }) { _, profile -> + ReorderableItem( + reorderableLazyListState, + key = profile.id, + ) { isDragging -> + ProfilePickerRow( + profile = profile, + isSelected = profile.id == selectedProfileId, + isDragging = isDragging, + onSelect = { + onProfileSelected(profile) + onDismiss() + }, + onEdit = { onProfileEdit(profile) }, + onShare = { + coroutineScope.launch(Dispatchers.IO) { + try { + context.shareProfile(profile) + } catch (_: Exception) { + } + } + }, + onShareURL = { + qrCodeProfile = profile + showQRCodeDialog = true + }, + onDelete = { onProfileDelete(profile) }, + modifier = Modifier.longPressDraggableHandle(), + ) + } + } + } + } + } + + if (showQRCodeDialog && qrCodeProfile != null) { + val profile = qrCodeProfile!! + val link = remember(profile) { + Libbox.generateRemoteProfileImportLink( + profile.name, + profile.typed.remoteURL, + ) + } + val surfaceColor = MaterialTheme.colorScheme.surface.toArgb() + val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor) + + QRCodeDialog( + bitmap = qrBitmap, + onDismiss = { + showQRCodeDialog = false + qrCodeProfile = null + }, + ) + } +} + +private suspend fun createProfileContent(profile: Profile): ByteArray { + val content = ProfileContent() + content.name = profile.name + when (profile.typed.type) { + TypedProfile.Type.Local -> { + content.type = Libbox.ProfileTypeLocal + } + TypedProfile.Type.Remote -> { + content.type = Libbox.ProfileTypeRemote + } + } + content.config = java.io.File(profile.typed.path).readText() + content.remotePath = profile.typed.remoteURL + content.autoUpdate = profile.typed.autoUpdate + content.autoUpdateInterval = profile.typed.autoUpdateInterval + content.lastUpdated = profile.typed.lastUpdated.time + return content.encode() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProfilePickerRow( + profile: Profile, + isSelected: Boolean, + isDragging: Boolean, + onSelect: () -> Unit, + onEdit: () -> Unit, + onShare: () -> Unit, + onShareURL: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var showMenu by remember { mutableStateOf(false) } + var expandedShareSubmenu by remember { mutableStateOf(false) } + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val animatedElevation by animateFloatAsState( + targetValue = when { + isDragging -> 8.dp.value + isSelected -> 2.dp.value + else -> 0.dp.value + }, + animationSpec = tween(300), + label = "Elevation", + ) + + val saveFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream"), + ) { uri -> + if (uri != null) { + coroutineScope.launch(Dispatchers.IO) { + try { + val profileData = createProfileContent(profile) + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(profileData) + } + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.success_profile_saved), + Toast.LENGTH_SHORT, + ).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "${context.getString(R.string.failed_save_profile)}: ${e.message}", + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + + Surface( + onClick = onSelect, + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = when { + isDragging -> MaterialTheme.colorScheme.tertiaryContainer + isSelected -> if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerLow, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.5f, + ) + } else { + MaterialTheme.colorScheme.surfaceDim + } + else -> if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerLow, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.35f, + ) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + } + }, + tonalElevation = animatedElevation.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val profileIcon = + ProfileIcons.getIconById(profile.icon) + ?: Icons.AutoMirrored.Default.InsertDriveFile + + Icon( + imageVector = profileIcon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = profile.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Text( + text = when (profile.typed.type) { + TypedProfile.Type.Local -> stringResource(R.string.profile_type_local) + TypedProfile.Type.Remote -> stringResource( + R.string.profile_type_remote_updated, + RelativeTimeFormatter.format(context, profile.typed.lastUpdated), + ) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Box { + IconButton( + onClick = { + showMenu = true + expandedShareSubmenu = false + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_options), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { + showMenu = false + expandedShareSubmenu = false + }, + modifier = Modifier.widthIn(min = 200.dp), + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.edit)) }, + onClick = { + showMenu = false + onEdit() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.menu_share)) }, + onClick = { + expandedShareSubmenu = !expandedShareSubmenu + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.IosShare, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = if (expandedShareSubmenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + + if (expandedShareSubmenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.save_as_file)) }, + onClick = { + showMenu = false + saveFileLauncher.launch("${profile.name}.bpf") + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.share_as_file)) }, + onClick = { + showMenu = false + onShare() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.IosShare, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + + if (profile.typed.type == TypedProfile.Type.Remote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.profile_share_url)) }, + onClick = { + showMenu = false + onShareURL() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + } + } + + DropdownMenuItem( + text = { + Text( + stringResource(R.string.menu_delete), + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + showMenu = false + onDelete() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt new file mode 100644 index 0000000000..31a5daae77 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfileSelectorButton.kt @@ -0,0 +1,95 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.filled.UnfoldMore +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.graphics.lerp +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.util.ProfileIcons +import io.nekohasekai.sfa.database.Profile + +@Composable +fun ProfileSelectorButton(selectedProfile: Profile?, onClick: () -> Unit, modifier: Modifier = Modifier) { + Surface( + onClick = onClick, + modifier = modifier.fillMaxWidth().height(48.dp), + shape = RoundedCornerShape(12.dp), + color = if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.5f, + ) + } else { + MaterialTheme.colorScheme.surfaceDim + }, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (selectedProfile != null) { + val profileIcon = + ProfileIcons.getIconById(selectedProfile.icon) + ?: Icons.AutoMirrored.Default.InsertDriveFile + + Icon( + imageVector = profileIcon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = selectedProfile.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } else { + Box(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.not_selected), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = stringResource(R.string.expand), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt new file mode 100644 index 0000000000..c4d9c8477d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/ProfilesCard.kt @@ -0,0 +1,905 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.IosShare +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.outlined.CreateNewFolder +import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.FileUpload +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +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.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.graphics.lerp +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.ProfileContent +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.component.qr.QRCodeDialog +import io.nekohasekai.sfa.compose.component.qr.QRSDialog +import io.nekohasekai.sfa.compose.component.qr.QRScanSheet +import io.nekohasekai.sfa.compose.navigation.NewProfileArgs +import io.nekohasekai.sfa.compose.screen.configuration.ProfileImportHandler +import io.nekohasekai.sfa.compose.screen.qrscan.QRScanResult +import io.nekohasekai.sfa.compose.util.QRCodeGenerator +import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.ktx.errorDialogBuilder +import io.nekohasekai.sfa.ktx.shareProfile +import io.nekohasekai.sfa.ktx.shareProfileAsJson +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfilesCard( + profiles: List, + selectedProfileId: Long, + isLoading: Boolean, + showAddProfileSheet: Boolean, + showProfilePickerSheet: Boolean, + updatingProfileId: Long? = null, + updatedProfileId: Long? = null, + onProfileSelected: (Long) -> Unit, + onProfileEdit: (Profile) -> Unit, + onProfileDelete: (Profile) -> Unit, + onProfileShare: (Profile) -> Unit, + onProfileShareURL: (Profile) -> Unit, + onProfileUpdate: (Profile) -> Unit, + onProfileMove: (Int, Int) -> Unit, + onShowAddProfileSheet: () -> Unit, + onHideAddProfileSheet: () -> Unit, + onShowProfilePickerSheet: () -> Unit, + onHideProfilePickerSheet: () -> Unit, + onOpenNewProfile: (NewProfileArgs) -> Unit, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val importHandler = remember { ProfileImportHandler(context) } + + var showQRCodeDialog by remember { mutableStateOf(false) } + var qrCodeProfile by remember { mutableStateOf(null) } + + var showQRSDialog by remember { mutableStateOf(false) } + var qrsProfile by remember { mutableStateOf(null) } + var qrsProfileData by remember { mutableStateOf(null) } + + var showImportConfirmDialog by remember { mutableStateOf(false) } + var pendingImportName by remember { mutableStateOf(null) } + var pendingQrsData by remember { mutableStateOf(null) } + var pendingImportUri by remember { mutableStateOf(null) } + + var showQRScanSheet by remember { mutableStateOf(false) } + + val importFromFileLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.GetContent(), + ) { uri -> + uri?.let { + coroutineScope.launch { + when (val parseResult = importHandler.parseUri(uri)) { + is ProfileImportHandler.UriParseResult.Success -> { + withContext(Dispatchers.Main) { + pendingImportName = parseResult.name + pendingImportUri = uri + showImportConfirmDialog = true + } + } + is ProfileImportHandler.UriParseResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(parseResult.message)).show() + } + } + } + } + } + } + + val saveFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/octet-stream"), + ) { uri -> + if (uri != null) { + val selectedProfile = profiles.find { it.id == selectedProfileId } + if (selectedProfile != null) { + coroutineScope.launch(Dispatchers.IO) { + try { + val profileData = createProfileContent(selectedProfile) + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(profileData) + } + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.success_profile_saved), + Toast.LENGTH_SHORT, + ).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "${context.getString(R.string.failed_save_profile)}: ${e.message}", + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + } + + val saveJsonFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/json"), + ) { uri -> + if (uri != null) { + val selectedProfile = profiles.find { it.id == selectedProfileId } + if (selectedProfile != null) { + coroutineScope.launch(Dispatchers.IO) { + try { + val jsonContent = File(selectedProfile.typed.path).readText() + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(jsonContent.toByteArray()) + } + withContext(Dispatchers.Main) { + Toast.makeText( + context, + context.getString(R.string.success_profile_saved), + Toast.LENGTH_SHORT, + ).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "${context.getString(R.string.failed_save_profile)}: ${e.message}", + Toast.LENGTH_SHORT, + ).show() + } + } + } + } + } + } + + val selectedProfile = profiles.find { it.id == selectedProfileId } + + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Description, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.title_configuration), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Surface( + onClick = onShowAddProfileSheet, + shape = RoundedCornerShape(12.dp), + color = if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.5f, + ) + } else { + MaterialTheme.colorScheme.surfaceDim + }, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(R.string.add_profile), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + if (profiles.isEmpty()) { + Text( + text = stringResource(R.string.no_profiles), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + ProfileSelectorButton( + selectedProfile = selectedProfile, + onClick = onShowProfilePickerSheet, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + ProfileInfoRow(profile = selectedProfile) + + Spacer(modifier = Modifier.height(16.dp)) + + ProfileActionRow( + profile = selectedProfile, + isUpdating = selectedProfile?.id == updatingProfileId, + showUpdateSuccess = selectedProfile?.id == updatedProfileId, + onEdit = { selectedProfile?.let { onProfileEdit(it) } }, + onUpdate = { selectedProfile?.let { onProfileUpdate(it) } }, + onShareFile = { + selectedProfile?.let { + coroutineScope.launch(Dispatchers.IO) { + try { + context.shareProfile(it) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(e).show() + } + } + } + } + }, + onSaveFile = { + selectedProfile?.let { + saveFileLauncher.launch("${it.name}.bpf") + } + }, + onSaveJson = { + selectedProfile?.let { + saveJsonFileLauncher.launch("${it.name}.json") + } + }, + onShareJson = { + selectedProfile?.let { + coroutineScope.launch(Dispatchers.IO) { + try { + context.shareProfileAsJson(it) + } catch (e: Exception) { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(e).show() + } + } + } + } + }, + onShareURL = { + selectedProfile?.let { + qrCodeProfile = it + showQRCodeDialog = true + } + }, + onShareQRS = { + selectedProfile?.let { profile -> + coroutineScope.launch(Dispatchers.IO) { + try { + val data = createProfileContent(profile) + withContext(Dispatchers.Main) { + qrsProfile = profile + qrsProfileData = data + showQRSDialog = true + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(e).show() + } + } + } + } + }, + ) + } + } + } + + if (showProfilePickerSheet) { + ProfilePickerSheet( + profiles = profiles, + selectedProfileId = selectedProfileId, + onProfileSelected = { profile -> onProfileSelected(profile.id) }, + onProfileEdit = onProfileEdit, + onProfileDelete = onProfileDelete, + onProfileMove = onProfileMove, + onDismiss = onHideProfilePickerSheet, + ) + } + + if (showAddProfileSheet) { + ModalBottomSheet( + onDismissRequest = onHideAddProfileSheet, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + ) { + Text( + text = stringResource(R.string.add_profile), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + ) + + ListItem( + modifier = Modifier.clickable { + onHideAddProfileSheet() + importFromFileLauncher.launch("*/*") + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.FileUpload, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + headlineContent = { + Text(stringResource(R.string.profile_add_import_file)) + }, + supportingContent = { + Text(stringResource(R.string.import_from_file_description)) + }, + ) + + ListItem( + modifier = Modifier.clickable { + onHideAddProfileSheet() + showQRScanSheet = true + }, + leadingContent = { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + headlineContent = { + Text(stringResource(R.string.profile_add_scan_qr_code)) + }, + supportingContent = { + Text(stringResource(R.string.scan_qr_code_description)) + }, + ) + + ListItem( + modifier = Modifier.clickable { + onHideAddProfileSheet() + onOpenNewProfile(NewProfileArgs()) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.CreateNewFolder, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + headlineContent = { + Text(stringResource(R.string.profile_add_create_manually)) + }, + supportingContent = { + Text(stringResource(R.string.create_new_profile_description)) + }, + ) + } + } + } + + if (showQRCodeDialog && qrCodeProfile != null) { + val profile = qrCodeProfile!! + val link = remember(profile) { + Libbox.generateRemoteProfileImportLink( + profile.name, + profile.typed.remoteURL, + ) + } + val surfaceColor = MaterialTheme.colorScheme.surface.toArgb() + val qrBitmap = QRCodeGenerator.rememberPrimaryBitmap(link, backgroundColor = surfaceColor) + + QRCodeDialog( + bitmap = qrBitmap, + onDismiss = { + showQRCodeDialog = false + qrCodeProfile = null + }, + ) + } + + if (showQRSDialog && qrsProfile != null && qrsProfileData != null) { + QRSDialog( + profileData = qrsProfileData!!, + profileName = qrsProfile!!.name, + onDismiss = { + showQRSDialog = false + qrsProfile = null + qrsProfileData = null + }, + ) + } + + if (showImportConfirmDialog && pendingImportName != null) { + AlertDialog( + onDismissRequest = { + showImportConfirmDialog = false + pendingImportName = null + pendingQrsData = null + pendingImportUri = null + }, + title = { Text(stringResource(R.string.import_profile_confirm_title)) }, + text = { Text(stringResource(R.string.import_profile_confirm_message, pendingImportName!!)) }, + confirmButton = { + TextButton( + onClick = { + showImportConfirmDialog = false + val qrsData = pendingQrsData + val importUri = pendingImportUri + pendingImportName = null + pendingQrsData = null + pendingImportUri = null + coroutineScope.launch { + if (qrsData != null) { + when (val result = importHandler.importFromQRSData(qrsData)) { + is ProfileImportHandler.ImportResult.Success -> { + withContext(Dispatchers.Main) { + onProfileEdit(result.profile) + } + } + is ProfileImportHandler.ImportResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(result.message)).show() + } + } + } + } else if (importUri != null) { + when (val result = importHandler.importFromUri(importUri)) { + is ProfileImportHandler.ImportResult.Success -> { + withContext(Dispatchers.Main) { + onProfileEdit(result.profile) + } + } + is ProfileImportHandler.ImportResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(result.message)).show() + } + } + } + } + } + }, + ) { + Text(stringResource(R.string.import_action)) + } + }, + dismissButton = { + TextButton( + onClick = { + showImportConfirmDialog = false + pendingImportName = null + pendingQrsData = null + pendingImportUri = null + }, + ) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + if (showQRScanSheet) { + QRScanSheet( + onDismiss = { showQRScanSheet = false }, + onScanResult = { result -> + showQRScanSheet = false + when (result) { + is QRScanResult.QRSData -> { + coroutineScope.launch { + when (val parseResult = importHandler.parseQRSData(result.data)) { + is ProfileImportHandler.QRSParseResult.Success -> { + withContext(Dispatchers.Main) { + pendingImportName = parseResult.name + pendingQrsData = result.data + showImportConfirmDialog = true + } + } + is ProfileImportHandler.QRSParseResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(parseResult.message)).show() + } + } + } + } + } + is QRScanResult.RemoteProfile -> { + coroutineScope.launch { + when (val parseResult = importHandler.parseQRCode(result.uri.toString())) { + is ProfileImportHandler.QRCodeParseResult.RemoteProfile -> { + withContext(Dispatchers.Main) { + onOpenNewProfile( + NewProfileArgs( + importName = parseResult.name, + importUrl = parseResult.url, + ), + ) + } + } + is ProfileImportHandler.QRCodeParseResult.LocalProfile -> { + when (val importResult = importHandler.importFromQRCode(result.uri.toString())) { + is ProfileImportHandler.ImportResult.Success -> { + withContext(Dispatchers.Main) { + onProfileEdit(importResult.profile) + } + } + is ProfileImportHandler.ImportResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(importResult.message)).show() + } + } + } + } + is ProfileImportHandler.QRCodeParseResult.Error -> { + withContext(Dispatchers.Main) { + context.errorDialogBuilder(Exception(parseResult.message)).show() + } + } + } + } + } + } + }, + ) + } +} + +private suspend fun createProfileContent(profile: Profile): ByteArray { + val content = ProfileContent() + content.name = profile.name + when (profile.typed.type) { + TypedProfile.Type.Local -> { + content.type = Libbox.ProfileTypeLocal + } + TypedProfile.Type.Remote -> { + content.type = Libbox.ProfileTypeRemote + } + } + content.config = java.io.File(profile.typed.path).readText() + content.remotePath = profile.typed.remoteURL + content.autoUpdate = profile.typed.autoUpdate + content.autoUpdateInterval = profile.typed.autoUpdateInterval + content.lastUpdated = profile.typed.lastUpdated.time + return content.encode() +} + +@Composable +private fun ProfileInfoRow(profile: Profile?) { + if (profile == null) return + + val context = LocalContext.current + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = if (profile.typed.type == TypedProfile.Type.Remote) { + Icons.Default.Cloud + } else { + Icons.Outlined.Description + }, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (profile.typed.type == TypedProfile.Type.Remote) { + stringResource(R.string.profile_type_remote) + } else { + stringResource(R.string.profile_type_local) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (profile.typed.type == TypedProfile.Type.Remote) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Default.AccessTime, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = RelativeTimeFormatter.format(context, profile.typed.lastUpdated), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun ProfileActionRow( + profile: Profile?, + isUpdating: Boolean, + showUpdateSuccess: Boolean, + onEdit: () -> Unit, + onUpdate: () -> Unit, + onShareFile: () -> Unit, + onSaveFile: () -> Unit, + onSaveJson: () -> Unit, + onShareJson: () -> Unit, + onShareURL: () -> Unit, + onShareQRS: () -> Unit, +) { + if (profile == null) return + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + ActionButton( + icon = Icons.Default.Edit, + contentDescription = stringResource(R.string.edit), + onClick = onEdit, + ) + + if (profile.typed.type == TypedProfile.Type.Remote) { + ActionButton( + icon = when { + showUpdateSuccess -> Icons.Default.Check + else -> Icons.Default.Refresh + }, + contentDescription = stringResource(R.string.update_profile), + onClick = onUpdate, + enabled = !isUpdating && !showUpdateSuccess, + isLoading = isUpdating, + ) + } + + ShareButton( + profile = profile, + onShareFile = onShareFile, + onSaveFile = onSaveFile, + onSaveJson = onSaveJson, + onShareJson = onShareJson, + onShareURL = onShareURL, + onShareQRS = onShareQRS, + ) + } +} + +@Composable +private fun ActionButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + enabled: Boolean = true, + isLoading: Boolean = false, +) { + Surface( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(12.dp), + color = if (isSystemInDarkTheme()) { + lerp( + MaterialTheme.colorScheme.surfaceContainerHighest, + MaterialTheme.colorScheme.surfaceContainerHigh, + 0.5f, + ) + } else { + MaterialTheme.colorScheme.surfaceDim + }, + modifier = Modifier.size(44.dp), + ) { + Box(contentAlignment = Alignment.Center) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Icon( + imageVector = icon, + contentDescription = contentDescription, + modifier = Modifier.size(20.dp), + tint = if (enabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) + } + } + } +} + +@Composable +private fun ShareButton( + profile: Profile, + onShareFile: () -> Unit, + onSaveFile: () -> Unit, + onSaveJson: () -> Unit, + onShareJson: () -> Unit, + onShareURL: () -> Unit, + onShareQRS: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + Box { + ActionButton( + icon = Icons.Default.IosShare, + contentDescription = stringResource(R.string.menu_share), + onClick = { expanded = true }, + ) + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.save_as_file)) }, + onClick = { + expanded = false + onSaveFile() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.share_as_file)) }, + onClick = { + expanded = false + onShareFile() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.IosShare, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.save_content_json)) }, + onClick = { + expanded = false + onSaveJson() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.DataObject, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.share_content_json)) }, + onClick = { + expanded = false + onShareJson() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.DataObject, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + if (profile.typed.type == TypedProfile.Type.Remote) { + DropdownMenuItem( + text = { Text(stringResource(R.string.profile_share_url)) }, + onClick = { + expanded = false + onShareURL() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.share_as_qrs)) }, + onClick = { + expanded = false + onShareQRS() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.QrCode2, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt new file mode 100644 index 0000000000..479ddb0da9 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/SystemProxyCard.kt @@ -0,0 +1,62 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +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.material.icons.Icons +import androidx.compose.material.icons.outlined.SettingsEthernet +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R + +@Composable +fun SystemProxyCard(enabled: Boolean, isSwitching: Boolean, onToggle: (Boolean) -> Unit, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.SettingsEthernet, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.system_http_proxy), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + Switch( + checked = enabled, + onCheckedChange = onToggle, + enabled = !isSwitching, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt new file mode 100644 index 0000000000..23ed1fb578 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/UploadTrafficCard.kt @@ -0,0 +1,79 @@ +package io.nekohasekai.sfa.compose.screen.dashboard + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.outlined.Upload +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.LineChart + +@Composable +fun UploadTrafficCard(uplink: String, uplinkTotal: String, uplinkHistory: List, modifier: Modifier = Modifier) { + Card( + modifier = modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Upload, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = stringResource(R.string.upload), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = uplink, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Text( + text = uplinkTotal, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + LineChart( + data = uplinkHistory, + lineColor = MaterialTheme.colorScheme.primary, + animate = false, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt new file mode 100644 index 0000000000..d18be3f682 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsScreen.kt @@ -0,0 +1,503 @@ +package io.nekohasekai.sfa.compose.screen.dashboard.groups + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.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.filled.ExpandMore +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material3.Card +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.SnackbarHostState +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.model.Group +import io.nekohasekai.sfa.compose.model.GroupItem +import io.nekohasekai.sfa.constant.Status + +@Composable +fun GroupsScreen( + serviceStatus: Status, + viewModel: GroupsViewModel = viewModel(), + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + onToggleAllGroups: () -> Unit = { viewModel.toggleAllGroups() }, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + + // Stable callbacks to prevent recomposition + val onToggleExpanded = + remember<(String) -> Unit> { + { groupTag -> viewModel.toggleGroupExpand(groupTag) } + } + val onItemSelected = + remember<(String, String) -> Unit> { + { groupTag, itemTag -> viewModel.selectGroupItem(groupTag, itemTag) } + } + val onUrlTest = + remember<(String) -> Unit> { + { groupTag -> viewModel.urlTest(groupTag) } + } + + LaunchedEffect(serviceStatus, viewModel) { + viewModel.updateServiceStatus(serviceStatus) + } + + // Show snackbar when needed + LaunchedEffect(uiState.showCloseConnectionsSnackbar) { + if (uiState.showCloseConnectionsSnackbar) { + val message = context.getString(R.string.close_connections_confirm) + val actionLabel = context.getString(R.string.close) + val result = + snackbarHostState.showSnackbar( + message = message, + actionLabel = actionLabel, + duration = androidx.compose.material3.SnackbarDuration.Indefinite, + withDismissAction = true, + ) + when (result) { + androidx.compose.material3.SnackbarResult.ActionPerformed -> { + viewModel.closeConnections() + } + androidx.compose.material3.SnackbarResult.Dismissed -> { + viewModel.dismissCloseConnectionsSnackbar() + } + } + } + } + + if (uiState.isLoading) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } else { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items( + items = uiState.groups, + key = { it.tag }, + contentType = { "GroupCard" }, + ) { group -> + ProxyGroupCard( + group = group, + isExpanded = uiState.expandedGroups.contains(group.tag), + onToggleExpanded = remember { { onToggleExpanded(group.tag) } }, + onItemSelected = remember { { itemTag -> onItemSelected(group.tag, itemTag) } }, + onUrlTest = remember { { onUrlTest(group.tag) } }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProxyGroupCard( + group: Group, + isExpanded: Boolean, + onToggleExpanded: () -> Unit, + onItemSelected: (String) -> Unit, + onUrlTest: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + // Header (clickable to expand/collapse) + Surface( + onClick = onToggleExpanded, + color = Color.Transparent, + ) { + ListItem( + headlineContent = { + Column { + Text( + text = group.tag, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = Libbox.proxyDisplayType(group.type), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Show selected item when collapsed + AnimatedVisibility( + visible = !isExpanded && group.selected.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "•", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = group.selected, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + }, + trailingContent = { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // URL Test button + AnimatedVisibility( + visible = group.selectable, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + IconButton( + onClick = { + onUrlTest() + // Don't toggle expansion when clicking URL test + }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Speed, + contentDescription = stringResource(R.string.url_test), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // Expand/Collapse indicator + val rotationAngle by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + animationSpec = tween(300), + label = "ExpandIcon", + ) + + val expandContentDescription = stringResource(R.string.expand) + val collapseContentDescription = stringResource(R.string.collapse) + Icon( + imageVector = Icons.Default.ExpandMore, + contentDescription = if (isExpanded) collapseContentDescription else expandContentDescription, + modifier = + Modifier + .size(24.dp) + .graphicsLayer { rotationZ = rotationAngle }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + // Expandable content + AnimatedVisibility( + visible = isExpanded && group.items.isNotEmpty(), + enter = expandVertically(animationSpec = tween(300)) + fadeIn(animationSpec = tween(300)), + exit = shrinkVertically(animationSpec = tween(300)) + fadeOut(animationSpec = tween(300)), + ) { + Column { + androidx.compose.material3.HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + thickness = 1.dp, + ) + + // Proxy Items + ProxyItemsList( + items = group.items, + selectedTag = group.selected, + isSelectable = group.selectable, + onItemSelected = onItemSelected, + ) + } + } + } + } +} + +@Composable +private fun ProxyItemsList(items: List, selectedTag: String, isSelectable: Boolean, onItemSelected: (String) -> Unit) { + // Cache the chunked items to avoid re-chunking on every recomposition + val itemsPerRow = 2 + val chunkedItems = + remember(items) { + items.chunked(itemsPerRow) + } + + // Use Column with Rows for better control over item sizing + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + chunkedItems.forEach { rowItems -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + rowItems.forEach { item -> + Box( + modifier = Modifier.weight(1f), + ) { + ProxyChip( + item = item, + isSelected = item.tag == selectedTag, + isSelectable = isSelectable, + onClick = remember { { onItemSelected(item.tag) } }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + // Add empty boxes for incomplete rows to maintain equal sizing + repeat(itemsPerRow - rowItems.size) { + Box(modifier = Modifier.weight(1f)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ProxyChip(item: GroupItem, isSelected: Boolean, isSelectable: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + // Use simpler, faster animations + val animatedElevation by animateFloatAsState( + targetValue = if (isSelected) 6.dp.value else 1.dp.value, + animationSpec = tween(150), + label = "Elevation", + ) + + val surfaceModifier = modifier + val surfaceShape = RoundedCornerShape(8.dp) + val surfaceColor = + when { + isSelected -> MaterialTheme.colorScheme.primaryContainer + else -> MaterialTheme.colorScheme.surface + } + val surfaceBorder = + androidx.compose.foundation.BorderStroke( + width = if (isSelected) 2.dp else 1.dp, + color = + when { + isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + }, + ) + + val content: @Composable () -> Unit = { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // First line: Name + Text( + text = item.tag, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + // Second line: Type on left, Latency on right + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Type + Text( + text = Libbox.proxyDisplayType(item.type), + style = MaterialTheme.typography.labelSmall, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f) + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + }, + ) + + // Latency + AnimatedVisibility( + visible = item.urlTestTime > 0, + enter = fadeIn(), + exit = fadeOut(), + ) { + ProxyLatencyBadge( + delay = item.urlTestDelay, + isSelected = isSelected, + ) + } + } + } + } + } + + if (isSelectable) { + Surface( + onClick = onClick, + modifier = surfaceModifier, + shape = surfaceShape, + color = surfaceColor, + tonalElevation = animatedElevation.dp, + border = surfaceBorder, + content = content, + ) + } else { + Surface( + modifier = surfaceModifier, + shape = surfaceShape, + color = surfaceColor, + tonalElevation = animatedElevation.dp, + border = surfaceBorder, + content = content, + ) + } +} + +@Composable +private fun ProxyLatencyBadge(delay: Int, isSelected: Boolean, modifier: Modifier = Modifier) { + // Direct color calculation without animation for better performance + val colorScheme = MaterialTheme.colorScheme + val latencyColor = + remember(delay, isSelected, colorScheme) { + when { + delay < 100 -> { + // Excellent - green/tertiary + if (isSelected) { + colorScheme.tertiary + } else { + colorScheme.tertiary.copy(alpha = 0.9f) + } + } + + delay < 300 -> { + // Good - primary + if (isSelected) { + colorScheme.primary + } else { + colorScheme.primary.copy(alpha = 0.9f) + } + } + + delay < 500 -> { + // Fair - secondary/warning + if (isSelected) { + colorScheme.secondary + } else { + colorScheme.secondary.copy(alpha = 0.9f) + } + } + + else -> { + // Poor - error + if (isSelected) { + colorScheme.error + } else { + colorScheme.error.copy(alpha = 0.9f) + } + } + } + } + + Text( + text = "${delay}ms", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = latencyColor, + modifier = modifier, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt new file mode 100644 index 0000000000..5d1c0f83bb --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/dashboard/groups/GroupsViewModel.kt @@ -0,0 +1,320 @@ +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 +import io.nekohasekai.sfa.compose.model.Group +import io.nekohasekai.sfa.compose.model.GroupItem +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +data class GroupsUiState( + val groups: List = emptyList(), + val isLoading: Boolean = false, + val expandedGroups: Set = emptySet(), + val showCloseConnectionsSnackbar: Boolean = false, +) + +sealed class GroupsEvent : ScreenEvent { + data class GroupSelected(val groupTag: String, val itemTag: String) : GroupsEvent() +} + +class GroupsViewModel(private val sharedCommandClient: CommandClient? = null) : + BaseViewModel(), + CommandClient.Handler { + private val commandClient: CommandClient + private val isUsingSharedClient: Boolean + + private val _serviceStatus = MutableStateFlow(Status.Stopped) + val serviceStatus = _serviceStatus.asStateFlow() + private var lastServiceStatus: Status = Status.Stopped + + init { + if (sharedCommandClient != null) { + commandClient = sharedCommandClient + isUsingSharedClient = true + commandClient.addHandler(this) + } else { + commandClient = + CommandClient( + viewModelScope, + CommandClient.ConnectionType.Groups, + this, + ) + isUsingSharedClient = false + } + + viewModelScope.launch { + AppLifecycleObserver.isForeground.collect { foreground -> + if (lastServiceStatus != Status.Started) return@collect + if (foreground) { + if (isUsingSharedClient) { + commandClient.addHandler(this@GroupsViewModel) + } else { + updateState { copy(isLoading = true) } + commandClient.connect() + } + } else { + if (isUsingSharedClient) { + commandClient.removeHandler(this@GroupsViewModel) + } else { + commandClient.disconnect() + } + } + } + } + } + + override fun createInitialState() = GroupsUiState() + + override fun onCleared() { + super.onCleared() + if (isUsingSharedClient) { + commandClient.removeHandler(this) + } else { + commandClient.disconnect() + } + } + + 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() + } + updateState { + copy( + groups = emptyList(), + isLoading = false, + ) + } + } + } + + fun updateServiceStatus(status: Status) { + if (status == lastServiceStatus) { + return + } + lastServiceStatus = status + viewModelScope.launch { + _serviceStatus.emit(status) + handleServiceStatusChange(status) + } + } + + fun toggleGroupExpand(groupTag: String) { + val newExpanded = !uiState.value.expandedGroups.contains(groupTag) + updateState { + val newExpandedGroups = if (newExpanded) { + expandedGroups + groupTag + } else { + expandedGroups - groupTag + } + copy(expandedGroups = newExpandedGroups) + } + viewModelScope.launch(Dispatchers.IO) { + runCatching { + Libbox.newStandaloneCommandClient().setGroupExpand(groupTag, newExpanded) + } + } + } + + fun toggleAllGroups() { + val groups = uiState.value.groups + val allCollapsed = uiState.value.expandedGroups.isEmpty() + val newExpanded = allCollapsed + + updateState { + if (allCollapsed) { + copy(expandedGroups = groups.map { it.tag }.toSet()) + } else { + copy(expandedGroups = emptySet()) + } + } + + viewModelScope.launch(Dispatchers.IO) { + groups.forEach { group -> + runCatching { + Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, newExpanded) + } + } + } + } + + fun selectGroupItem(groupTag: String, itemTag: String) { + // Check if this is actually a different selection + val currentGroup = uiState.value.groups.find { it.tag == groupTag } + if (currentGroup?.selected == itemTag) { + // Same item selected, no need to do anything + return + } + + viewModelScope.launch(Dispatchers.IO) { + try { + // Select the new outbound immediately + Libbox.newStandaloneCommandClient().selectOutbound(groupTag, itemTag) + + // Update local state and show snackbar + withContext(Dispatchers.Main) { + updateState { + copy( + groups = + groups.map { group -> + if (group.tag == groupTag) { + group.copy(selected = itemTag) + } else { + group + } + }, + showCloseConnectionsSnackbar = true, + ) + } + sendEvent(GroupsEvent.GroupSelected(groupTag, itemTag)) + } + } catch (e: Exception) { + sendError(e) + } + } + } + + fun closeConnections() { + viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient().closeConnections() + withContext(Dispatchers.Main) { + dismissCloseConnectionsSnackbar() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + dismissCloseConnectionsSnackbar() + } + sendError(e) + } + } + } + + fun dismissCloseConnectionsSnackbar() { + updateState { + copy(showCloseConnectionsSnackbar = false) + } + } + + fun urlTest(groupTag: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + Libbox.newStandaloneCommandClient().urlTest(groupTag) + } catch (e: Exception) { + sendError(e) + } + } + } + + // CommandClient.Handler implementation + override fun onConnected() { + viewModelScope.launch(Dispatchers.Main) { + // Connection established, waiting for groups + } + } + + override fun onDisconnected() { + viewModelScope.launch(Dispatchers.Main) { + updateState { + copy( + groups = emptyList(), + isLoading = false, + ) + } + } + } + + override fun updateGroups(newGroups: MutableList) { + viewModelScope.launch(Dispatchers.Default) { + val currentGroups = uiState.value.groups + val newGroupsMap = newGroups.associateBy { it.tag } + + // Smart merge: preserve existing Group objects when only delays change + val mergedGroups = + if (currentGroups.isEmpty()) { + // Initial load + newGroups.map(::Group) + } else { + currentGroups.map { existingGroup -> + val newGroupData = newGroupsMap[existingGroup.tag] + if (newGroupData != null) { + // Check if only delays have changed + val newItems = newGroupData.items.toList() + val hasStructuralChange = + existingGroup.items.size != newItems.size || + existingGroup.selected != newGroupData.selected || + existingGroup.type != newGroupData.type || + existingGroup.selectable != newGroupData.selectable + + if (hasStructuralChange) { + // Structural change, create new Group + Group(newGroupData) + } else { + // Only delays might have changed, update items efficiently + val updatedItems = + existingGroup.items.mapIndexed { index, item -> + val newItemData = newItems.getOrNull(index) + if (newItemData != null && + item.tag == newItemData.tag && + item.type == newItemData.type + ) { + // Only update if delay actually changed + if (item.urlTestDelay != newItemData.urlTestDelay || + item.urlTestTime != newItemData.urlTestTime + ) { + GroupItem(newItemData) + } else { + item // Keep existing object + } + } else { + if (newItemData != null) { + GroupItem(newItemData) + } else { + item // Keep existing if index out of bounds + } + } + } + existingGroup.copy(items = updatedItems) + } + } else { + existingGroup + } + } + + newGroups.filter { newGroup -> + currentGroups.none { it.tag == newGroup.tag } + }.map(::Group) + } + + withContext(Dispatchers.Main) { + updateState { + val initialExpandedGroups = if (expandedGroups.isEmpty() && currentGroups.isEmpty()) { + mergedGroups.filter { it.isExpand }.map { it.tag }.toSet() + } else { + expandedGroups + } + copy( + groups = mergedGroups, + expandedGroups = initialExpandedGroups, + isLoading = false, + ) + } + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt new file mode 100644 index 0000000000..9fe1a46298 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/BaseLogViewModel.kt @@ -0,0 +1,153 @@ +package io.nekohasekai.sfa.compose.screen.log + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import io.nekohasekai.sfa.compose.util.AnsiColorUtils +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicLong + +@OptIn(FlowPreview::class) +abstract class BaseLogViewModel : + ViewModel(), + LogViewerViewModel { + protected val _uiState = MutableStateFlow(LogUiState()) + override val uiState: StateFlow = _uiState.asStateFlow() + + protected val _autoScrollEnabled = MutableStateFlow(true) + override val isAtBottom: StateFlow = _autoScrollEnabled.asStateFlow() + + protected val _scrollToBottomTrigger = MutableStateFlow(0) + override val scrollToBottomTrigger: StateFlow = _scrollToBottomTrigger.asStateFlow() + + protected val _searchQueryInternal = MutableStateFlow("") + protected val logIdGenerator = AtomicLong(0) + protected val allLogs = LinkedList() + + init { + viewModelScope.launch { + _searchQueryInternal + .debounce(300) + .distinctUntilChanged() + .collect { + updateDisplayedLogs() + } + } + } + + override fun toggleSearch() { + _uiState.update { + it.copy( + isSearchActive = !it.isSearchActive, + searchQuery = if (!it.isSearchActive) it.searchQuery else "", + ) + } + updateDisplayedLogs() + } + + override fun toggleOptionsMenu() { + _uiState.update { it.copy(isOptionsMenuOpen = !it.isOptionsMenuOpen) } + } + + override fun updateSearchQuery(query: String) { + _uiState.update { it.copy(searchQuery = query) } + _searchQueryInternal.value = query + } + + override fun setLogLevel(level: LogLevel) { + _uiState.update { it.copy(filterLogLevel = level) } + updateDisplayedLogs() + } + + override fun setAutoScrollEnabled(enabled: Boolean) { + _autoScrollEnabled.value = enabled + } + + override fun scrollToBottom() { + _autoScrollEnabled.value = true + _scrollToBottomTrigger.value++ + } + + override fun toggleSelectionMode() { + _uiState.update { + if (it.isSelectionMode) { + it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false) + } else { + it.copy(isSelectionMode = true, isPaused = true) + } + } + } + + override fun toggleLogSelection(index: Int) { + _uiState.update { state -> + val newSelection = + if (state.selectedLogIndices.contains(index)) { + state.selectedLogIndices - index + } else { + state.selectedLogIndices + index + } + if (newSelection.isEmpty()) { + state.copy( + isSelectionMode = false, + selectedLogIndices = emptySet(), + isPaused = false, + ) + } else { + state.copy(selectedLogIndices = newSelection) + } + } + } + + override fun clearSelection() { + _uiState.update { + it.copy(isSelectionMode = false, selectedLogIndices = emptySet(), isPaused = false) + } + } + + override fun getSelectedLogsText(): String { + val state = _uiState.value + return state.selectedLogIndices + .sorted() + .mapNotNull { index -> + state.logs.getOrNull(index)?.entry?.message?.let { AnsiColorUtils.stripAnsi(it) } + } + .joinToString("\n") + } + + override fun getAllLogsText(): String = _uiState.value.logs.joinToString("\n") { AnsiColorUtils.stripAnsi(it.entry.message) } + + protected fun updateDisplayedLogs() { + val currentState = _uiState.value + val levelPriority = + if (currentState.filterLogLevel != LogLevel.Default) { + currentState.filterLogLevel.priority + } else { + currentState.defaultLogLevel.priority + } + val searchQuery = currentState.searchQuery + + val logsToDisplay = + allLogs.asSequence() + .filter { log -> log.entry.level.priority <= levelPriority } + .filter { log -> + searchQuery.isEmpty() || log.entry.message.contains(searchQuery, ignoreCase = true) + } + .toList() + + val selectionCleared = + if (_uiState.value.isSelectionMode && _uiState.value.logs != logsToDisplay) { + emptySet() + } else { + _uiState.value.selectedLogIndices + } + + _uiState.update { it.copy(logs = logsToDisplay, selectedLogIndices = selectionCleared) } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt new file mode 100644 index 0000000000..c5ed95ef61 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogScreen.kt @@ -0,0 +1,32 @@ +package io.nekohasekai.sfa.compose.screen.log + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.constant.Status + +@Composable +fun HookLogScreen(onBack: () -> Unit) { + val viewModel: HookLogViewModel = viewModel() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.loadLogs(context) + } + + LogScreen( + serviceStatus = Status.Stopped, + showStartFab = false, + showStatusBar = false, + title = context.getString(R.string.title_log), + viewModel = viewModel, + showPause = false, + showClear = false, + showStatusInfo = false, + emptyMessage = context.getString(R.string.privilege_settings_hook_logs_empty), + saveFilePrefix = "hook_logs", + onBack = onBack, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt new file mode 100644 index 0000000000..777bf50685 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/HookLogViewModel.kt @@ -0,0 +1,117 @@ +package io.nekohasekai.sfa.compose.screen.log + +import android.content.Context +import android.text.format.DateFormat +import androidx.lifecycle.viewModelScope +import io.nekohasekai.sfa.bg.LogEntry +import io.nekohasekai.sfa.compose.util.AnsiColorUtils +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.utils.HookErrorClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Date + +class HookLogViewModel : BaseLogViewModel() { + + fun loadLogs(context: Context) { + viewModelScope.launch { + val result = withContext(Dispatchers.IO) { + HookErrorClient.query(context) + } + if (result.failure != null) { + val detail = buildErrorMessage(result) + allLogs.clear() + _uiState.update { + it.copy( + logs = emptyList(), + isConnected = false, + errorTitle = "Error", + errorMessage = detail, + ) + } + return@launch + } + val logs = result.logs.map { processLogEntry(it) } + allLogs.clear() + allLogs.addAll(logs) + _uiState.update { + it.copy( + logs = emptyList(), + isConnected = true, + errorTitle = null, + errorMessage = null, + ) + } + updateDisplayedLogs() + } + } + + private companion object { + private const val ANSI_RESET = "\u001B[0m" + private const val ANSI_RED = "\u001B[31m" + private const val ANSI_YELLOW = "\u001B[33m" + private const val ANSI_CYAN = "\u001B[36m" + private const val ANSI_WHITE = "\u001B[37m" + } + + private fun processLogEntry(entry: LogEntry): ProcessedLogEntry { + val level = when (entry.level) { + LogEntry.LEVEL_DEBUG -> LogLevel.DEBUG + LogEntry.LEVEL_INFO -> LogLevel.INFO + LogEntry.LEVEL_WARN -> LogLevel.WARNING + LogEntry.LEVEL_ERROR -> LogLevel.ERROR + else -> LogLevel.Default + } + val (levelName, levelColor) = when (entry.level) { + LogEntry.LEVEL_DEBUG -> "DEBUG" to ANSI_WHITE + LogEntry.LEVEL_INFO -> "INFO" to ANSI_CYAN + LogEntry.LEVEL_WARN -> "WARN" to ANSI_YELLOW + LogEntry.LEVEL_ERROR -> "ERROR" to ANSI_RED + else -> "UNKNOWN" to ANSI_WHITE + } + val timestamp = DateFormat.format("HH:mm:ss", Date(entry.timestamp)).toString() + val message = buildString { + append(levelColor).append(levelName).append(ANSI_RESET) + append("[").append(timestamp).append("] ") + append("[").append(entry.source).append("]: ") + append(entry.message) + if (!entry.stackTrace.isNullOrEmpty()) { + append("\n").append(entry.stackTrace) + } + } + return ProcessedLogEntry( + id = logIdGenerator.incrementAndGet(), + entry = LogEntryData(level, AnsiColorUtils.stripAnsi(message)), + annotatedString = AnsiColorUtils.ansiToAnnotatedString(message), + ) + } + + private fun buildErrorMessage(result: HookErrorClient.Result): String { + val message = when (result.failure) { + HookErrorClient.Failure.SERVICE_UNAVAILABLE -> + "Connectivity service unavailable. Reboot or activate LSPosed module." + HookErrorClient.Failure.TRANSACTION_FAILED -> + "Hook transaction rejected. Reboot to load LSPosed module." + HookErrorClient.Failure.REMOTE_ERROR -> + "Remote error while reading logs." + HookErrorClient.Failure.PROTOCOL_ERROR -> + "Log protocol mismatch. Reboot to update LSPosed module." + null -> "Unknown error." + } + val detail = result.detail?.takeIf { it.isNotBlank() } + return if (detail != null) "$message\n$detail" else message + } + + override fun updateServiceStatus(status: Status) { + _uiState.update { it.copy(serviceStatus = status) } + } + + override fun togglePause() { + _uiState.update { it.copy(isPaused = false) } + } + + override fun requestClearLogs() { + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt new file mode 100644 index 0000000000..aad3eaffe1 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogModels.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sfa.compose.screen.log + +import androidx.compose.ui.text.AnnotatedString +import io.nekohasekai.sfa.constant.Status + +data class LogEntryData(val level: LogLevel, val message: String) + +data class ProcessedLogEntry(val id: Long, val entry: LogEntryData, val annotatedString: AnnotatedString) + +enum class LogLevel(val label: String, val priority: Int) { + Default("Default", 7), + + PANIC("Panic", 0), + FATAL("Fatal", 1), + ERROR("Error", 2), + WARNING("Warn", 3), + INFO("Info", 4), + DEBUG("Debug", 5), + TRACE("Trace", 6), +} + +data class LogUiState( + val logs: List = emptyList(), + val isConnected: Boolean = false, + val serviceStatus: Status = Status.Stopped, + val isPaused: Boolean = false, + val searchQuery: String = "", + val isSearchActive: Boolean = false, + val defaultLogLevel: LogLevel = LogLevel.Default, + val filterLogLevel: LogLevel = LogLevel.Default, + val isOptionsMenuOpen: Boolean = false, + val isSelectionMode: Boolean = false, + val selectedLogIndices: Set = emptySet(), + val errorTitle: String? = null, + val errorMessage: String? = null, +) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt new file mode 100644 index 0000000000..e4ab1f49ba --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogScreen.kt @@ -0,0 +1,949 @@ +package io.nekohasekai.sfa.compose.screen.log + +import android.content.ClipData +import android.content.Intent +import android.os.Build +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.DragInteraction +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +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.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Share +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.FloatingActionButton +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.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.lifecycle.viewmodel.compose.viewModel +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.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LogScreen( + serviceStatus: Status = Status.Stopped, + showStartFab: Boolean = false, + showStatusBar: Boolean = false, + title: String? = null, + viewModel: LogViewerViewModel? = null, + showPause: Boolean = true, + showClear: Boolean = true, + showStatusInfo: Boolean = true, + emptyMessage: String? = null, + saveFilePrefix: String = "logs", + onBack: (() -> Unit)? = null, +) { + val resolvedViewModel = viewModel ?: viewModel() + val uiState by resolvedViewModel.uiState.collectAsState() + val context = LocalContext.current + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isTablet = windowSizeClass.isWidthAtLeastBreakpointCompat(WindowSizeClassCompat.WIDTH_DP_MEDIUM_LOWER_BOUND) + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val resolvedTitle = title ?: stringResource(R.string.title_log) + val emptyStateMessage = emptyMessage ?: stringResource(R.string.privilege_settings_hook_logs_empty) + + OverrideTopBar { + TopAppBar( + title = { Text(resolvedTitle) }, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + } + }, + actions = { + if (!uiState.isSelectionMode) { + if (showPause) { + IconButton(onClick = { resolvedViewModel.togglePause() }) { + Icon( + imageVector = + if (uiState.isPaused) { + Icons.Default.PlayArrow + } else { + Icons.Default.Pause + }, + contentDescription = + if (uiState.isPaused) { + stringResource(R.string.content_description_resume_logs) + } else { + stringResource(R.string.content_description_pause_logs) + }, + ) + } + } + + IconButton(onClick = { resolvedViewModel.toggleSearch() }) { + Icon( + imageVector = + if (uiState.isSearchActive) { + Icons.Default.ExpandLess + } else { + Icons.Default.Search + }, + contentDescription = + if (uiState.isSearchActive) { + stringResource(R.string.content_description_collapse_search) + } else { + stringResource(R.string.content_description_search_logs) + }, + tint = + if (uiState.isSearchActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + IconButton(onClick = { resolvedViewModel.toggleOptionsMenu() }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringResource(R.string.more_options), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + }, + ) + } + + // Handle back press in selection mode + androidx.activity.compose.BackHandler(enabled = uiState.isSelectionMode) { + resolvedViewModel.clearSelection() + } + + // Track if user is at the bottom of the list + val isAtBottom by remember { + derivedStateOf { + val layoutInfo = listState.layoutInfo + val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull() + lastVisibleItem != null && lastVisibleItem.index == layoutInfo.totalItemsCount - 1 + } + } + + // Re-enable auto-scroll when user reaches bottom + LaunchedEffect(isAtBottom) { + if (isAtBottom) { + resolvedViewModel.setAutoScrollEnabled(true) + } + } + + // Detect user manual scroll to disable auto-scroll + LaunchedEffect(listState) { + var dragStartIndex: Int? = null + var dragStartOffset: Int? = null + + listState.interactionSource.interactions.collect { interaction -> + when (interaction) { + is DragInteraction.Start -> { + dragStartIndex = listState.firstVisibleItemIndex + dragStartOffset = listState.firstVisibleItemScrollOffset + } + is DragInteraction.Stop, is DragInteraction.Cancel -> { + if (dragStartIndex != null && dragStartOffset != null) { + val currentIndex = listState.firstVisibleItemIndex + val currentOffset = listState.firstVisibleItemScrollOffset + + val scrolledUp = + if (dragStartIndex != currentIndex) { + dragStartIndex!! > currentIndex + } else { + dragStartOffset!! > currentOffset + } + + if (scrolledUp) { + resolvedViewModel.setAutoScrollEnabled(false) + } + + dragStartIndex = null + dragStartOffset = null + } + } + } + } + } + + // Handle scroll to bottom requests from ViewModel + val scrollToBottomTrigger by resolvedViewModel.scrollToBottomTrigger.collectAsState() + LaunchedEffect(scrollToBottomTrigger) { + if (scrollToBottomTrigger > 0 && uiState.logs.isNotEmpty()) { + listState.animateScrollToItem(uiState.logs.size - 1) + } + } + + // Update service status in ViewModel + LaunchedEffect(serviceStatus) { + if (showStatusInfo) { + resolvedViewModel.updateServiceStatus(serviceStatus) + } + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Show selection mode bar + if (uiState.isSelectionMode) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + shadowElevation = 2.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { resolvedViewModel.clearSelection() }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.content_description_exit_selection_mode), + ) + } + Text( + text = + stringResource( + R.string.selected_count, + uiState.selectedLogIndices.size, + ), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(start = 8.dp), + ) + } + Row { + IconButton( + onClick = { + val selectedText = resolvedViewModel.getSelectedLogsText() + if (selectedText.isNotEmpty()) { + val clipLabel = resolvedTitle + val clip = ClipData.newPlainText(clipLabel, selectedText) + Application.clipboard.setPrimaryClip(clip) + Toast.makeText( + context, + context.getString(R.string.copied_to_clipboard), + Toast.LENGTH_SHORT, + ).show() + resolvedViewModel.clearSelection() + } + }, + enabled = uiState.selectedLogIndices.isNotEmpty(), + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.content_description_copy_selected), + ) + } + } + } + } + } + + // Show active filter indicator + if (uiState.filterLogLevel != LogLevel.Default && !uiState.isSelectionMode) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + stringResource( + R.string.filter_label, + uiState.filterLogLevel.label, + ), + style = MaterialTheme.typography.bodySmall, + ) + TextButton( + onClick = { resolvedViewModel.setLogLevel(LogLevel.Default) }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + modifier = Modifier.height(24.dp), + ) { + Text( + text = stringResource(R.string.clear_filter), + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + + // Show search bar with animation + AnimatedVisibility( + visible = uiState.isSearchActive, + enter = + expandVertically( + animationSpec = tween(300), + ) + + fadeIn( + animationSpec = tween(300), + ), + exit = + shrinkVertically( + animationSpec = tween(300), + ) + + fadeOut( + animationSpec = tween(300), + ), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 4.dp, + ) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = { resolvedViewModel.updateSearchQuery(it) }, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp) + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.search_logs_placeholder)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search), + ) + }, + trailingIcon = { + if (uiState.searchQuery.isNotEmpty()) { + IconButton(onClick = { resolvedViewModel.updateSearchQuery("") }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.content_description_clear_search), + ) + } + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = + KeyboardActions( + onSearch = { + focusManager.clearFocus() + }, + ), + ) + } + } + + if (uiState.errorMessage != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = uiState.errorTitle ?: "Error", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Text( + text = uiState.errorMessage ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else if (uiState.logs.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + 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) + else -> stringResource(R.string.status_default) + } + } else { + emptyStateMessage + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + // Log list + val bottomPadding = when { + showStartFab -> 88.dp + showStatusBar -> 74.dp + else -> 0.dp + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + start = 8.dp, + end = 8.dp, + top = 8.dp, + bottom = bottomPadding, + ), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + itemsIndexed( + items = uiState.logs, + key = { _, log -> log.id }, + ) { index, log -> + LogItem( + annotatedString = log.annotatedString, + index = index, + isSelected = uiState.selectedLogIndices.contains(index), + isSelectionMode = uiState.isSelectionMode, + onLongClick = { + if (!uiState.isSelectionMode) { + resolvedViewModel.toggleSelectionMode() + resolvedViewModel.toggleLogSelection(index) + } + }, + onClick = { + if (uiState.isSelectionMode) { + resolvedViewModel.toggleLogSelection(index) + } + }, + ) + } + } + } + } // Close Column + + // Options Menu - Material 3 style + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .padding(end = 8.dp), + ) { + var expandedLogLevel by remember { mutableStateOf(false) } + var expandedSave by remember { mutableStateOf(false) } + + // File save launcher (must be outside DropdownMenu) + val saveFileLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("text/plain"), + onResult = { uri -> + uri?.let { + try { + context.contentResolver.openOutputStream(it)?.use { outputStream -> + val logsText = resolvedViewModel.getAllLogsText() + outputStream.write(logsText.toByteArray()) + outputStream.flush() + Toast.makeText( + context, + context.getString(R.string.success_logs_saved), + Toast.LENGTH_SHORT, + ).show() + } + } catch (e: Exception) { + Toast.makeText( + context, + context.getString(R.string.failed_save_logs, e.message), + Toast.LENGTH_SHORT, + ).show() + } + } + }, + ) + + DropdownMenu( + expanded = uiState.isOptionsMenuOpen, + onDismissRequest = { + resolvedViewModel.toggleOptionsMenu() + expandedLogLevel = false + expandedSave = false + }, + modifier = Modifier.widthIn(min = 200.dp), + ) { + // Log Level section with nested items + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.log_level), + style = MaterialTheme.typography.bodyLarge, + ) + }, + onClick = { expandedLogLevel = !expandedLogLevel }, + leadingIcon = { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (expandedLogLevel) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + + // Show log levels inline when expanded + if (expandedLogLevel) { + LogLevel.entries.filter { it.priority > 1 }.forEach { level -> + DropdownMenuItem( + text = { + Text(text = level.label) + }, + onClick = { + resolvedViewModel.setLogLevel(level) + resolvedViewModel.toggleOptionsMenu() + expandedLogLevel = false + }, + leadingIcon = { + Icon( + imageVector = + if (uiState.filterLogLevel == level) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = + if (uiState.filterLogLevel == level) { + stringResource(R.string.group_selected_title) + } else { + null + }, + tint = + if (uiState.filterLogLevel == level) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + + // Save section with nested items + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.save), + style = MaterialTheme.typography.bodyLarge, + ) + }, + onClick = { expandedSave = !expandedSave }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (expandedSave) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + + // Show save options inline when expanded + if (expandedSave) { + // Copy to Clipboard + DropdownMenuItem( + text = { + Text(text = stringResource(R.string.save_to_clipboard)) + }, + onClick = { + val logsText = resolvedViewModel.getAllLogsText() + if (logsText.isNotEmpty()) { + val clip = + ClipData.newPlainText(resolvedTitle, logsText) + Application.clipboard.setPrimaryClip(clip) + Toast.makeText( + context, + context.getString(R.string.logs_copied_to_clipboard), + Toast.LENGTH_SHORT, + ).show() + } else { + Toast.makeText( + context, + context.getString(R.string.no_logs_to_copy), + Toast.LENGTH_SHORT, + ).show() + } + resolvedViewModel.toggleOptionsMenu() + expandedSave = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + + // Save to File + DropdownMenuItem( + text = { + Text(text = stringResource(R.string.save_to_file)) + }, + onClick = { + val timestamp = + SimpleDateFormat( + "yyyyMMdd_HHmmss", + Locale.getDefault(), + ).format(Date()) + saveFileLauncher.launch("${saveFilePrefix}_$timestamp.txt") + resolvedViewModel.toggleOptionsMenu() + expandedSave = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + + // Share as File + DropdownMenuItem( + text = { + Text(text = stringResource(R.string.menu_share)) + }, + onClick = { + val logsText = resolvedViewModel.getAllLogsText() + if (logsText.isNotEmpty()) { + try { + val logsDir = + File(context.cacheDir, "logs").also { it.mkdirs() } + val timestamp = + SimpleDateFormat( + "yyyyMMdd_HHmmss", + Locale.getDefault(), + ).format(Date()) + val logFile = File(logsDir, "${saveFilePrefix}_$timestamp.txt") + logFile.writeText(logsText) + + val uri = + FileProvider.getUriForFile( + context, + "${context.packageName}.cache", + logFile, + ) + val shareIntent = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity( + Intent.createChooser( + shareIntent, + context.getString(R.string.intent_share_logs), + ), + ) + } catch (e: Exception) { + Toast.makeText( + context, + context.getString(R.string.failed_share_logs, e.message), + Toast.LENGTH_SHORT, + ).show() + } + } else { + Toast.makeText( + context, + context.getString(R.string.no_logs_to_share), + Toast.LENGTH_SHORT, + ).show() + } + resolvedViewModel.toggleOptionsMenu() + expandedSave = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + + if (showClear) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.clear_logs), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + }, + onClick = { + resolvedViewModel.requestClearLogs() + resolvedViewModel.toggleOptionsMenu() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + } + + // FABs - Hide during selection mode + val padFabVisible = isTablet && (showStartFab || showStatusBar) + val fabBottomPadding = when { + padFabVisible -> 20.dp + 64.dp + 16.dp + showStartFab -> 88.dp + showStatusBar -> 74.dp + else -> 16.dp + } + val fabEndPadding = if (isTablet) 20.dp else 16.dp + Column( + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(bottom = fabBottomPadding, end = fabEndPadding, top = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Scroll to bottom FAB + // Use fade animation on API 23 to avoid OpenGLRenderer crash with scale transforms + AnimatedVisibility( + visible = !isAtBottom && !uiState.isSelectionMode && uiState.logs.isNotEmpty(), + enter = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleIn() else fadeIn(), + exit = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) scaleOut() else fadeOut(), + ) { + FloatingActionButton( + onClick = { resolvedViewModel.scrollToBottom() }, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = stringResource(R.string.content_description_scroll_to_bottom), + ) + } + } + } + } // Close Box that contains Column, Options Menu and FAB +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LogItem( + annotatedString: androidx.compose.ui.text.AnnotatedString, + index: Int, + isSelected: Boolean, + isSelectionMode: Boolean, + onLongClick: () -> Unit, + onClick: () -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .combinedClickable( + onClick = onClick, + onLongClick = onLongClick, + ), + shape = RoundedCornerShape(4.dp), + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + }, + ), + border = + if (isSelected) { + CardDefaults.outlinedCardBorder().copy( + width = 2.dp, + brush = + androidx.compose.ui.graphics.SolidColor( + MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + ), + ) + } else { + null + }, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelectionMode) { + Icon( + imageVector = if (isSelected) Icons.Default.CheckBox else Icons.Default.CheckBoxOutlineBlank, + contentDescription = + if (isSelected) { + stringResource(R.string.group_selected_title) + } else { + stringResource( + R.string.not_selected, + ) + }, + modifier = Modifier.padding(start = 12.dp, end = 4.dp), + tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = annotatedString, + modifier = + Modifier + .weight(1f) + .padding( + start = if (isSelectionMode) 4.dp else 12.dp, + end = 12.dp, + top = 8.dp, + bottom = 8.dp, + ), + fontSize = 13.sp, + fontFamily = FontFamily.Monospace, + lineHeight = 18.sp, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt new file mode 100644 index 0000000000..601a51d91d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewModel.kt @@ -0,0 +1,152 @@ +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.LinkedList + +class LogViewModel : + BaseLogViewModel(), + CommandClient.Handler { + companion object { + private val maxLines = 3000 + } + + private val bufferedLogs = LinkedList() + private val commandClient = + CommandClient( + scope = viewModelScope, + connectionType = CommandClient.ConnectionType.Log, + handler = this, + ) + private var lastServiceStatus: Status = Status.Stopped + + init { + viewModelScope.launch { + AppLifecycleObserver.isForeground.collect { foreground -> + if (lastServiceStatus != Status.Started) return@collect + if (foreground) { + commandClient.connect() + } else { + commandClient.disconnect() + } + } + } + } + + private fun processLogEntry(entry: LogEntry): ProcessedLogEntry { + val level = LogLevel.entries.find { it.priority == entry.level } ?: LogLevel.Default + return ProcessedLogEntry( + id = logIdGenerator.incrementAndGet(), + entry = LogEntryData(level = level, message = entry.message), + annotatedString = AnsiColorUtils.ansiToAnnotatedString(entry.message), + ) + } + + override fun updateServiceStatus(status: Status) { + lastServiceStatus = status + _uiState.update { it.copy(serviceStatus = status) } + + when (status) { + Status.Started -> { + if (AppLifecycleObserver.isForeground.value) { + commandClient.connect() + } + } + + Status.Stopped, Status.Stopping -> { + commandClient.disconnect() + _uiState.update { it.copy(isConnected = false) } + } + + else -> {} + } + } + + override fun onConnected() { + _uiState.update { it.copy(isConnected = true) } + } + + override fun onDisconnected() { + _uiState.update { it.copy(isConnected = false) } + } + + override fun setDefaultLogLevel(level: Int) { + val logLevel = LogLevel.entries.find { it.priority == level } ?: error("Unknown log level: $level") + _uiState.update { it.copy(defaultLogLevel = logLevel) } + updateDisplayedLogs() + } + + override fun clearLogs() { + allLogs.clear() + bufferedLogs.clear() + _uiState.update { it.copy(isPaused = false) } + updateDisplayedLogs() + } + + override fun requestClearLogs() { + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { + Libbox.newStandaloneCommandClient().clearLogs() + } + } + } + } + + override fun appendLogs(message: List) { + val processedLogs = message.map { processLogEntry(it) } + if (_uiState.value.isPaused) { + bufferedLogs.addAll(processedLogs) + } else { + val totalSize = allLogs.size + processedLogs.size + val removeCount = (totalSize - maxLines).coerceAtLeast(0) + + if (removeCount > 0) { + repeat(removeCount) { + allLogs.removeFirst() + } + } + + allLogs.addAll(processedLogs) + updateDisplayedLogs() + + if (_autoScrollEnabled.value && !_uiState.value.isPaused && !_uiState.value.isSearchActive) { + scrollToBottom() + } + } + } + + override fun togglePause() { + val currentState = _uiState.value + if (currentState.isPaused && bufferedLogs.isNotEmpty()) { + val totalSize = allLogs.size + bufferedLogs.size + val removeCount = (totalSize - maxLines).coerceAtLeast(0) + + if (removeCount > 0) { + repeat(removeCount) { + allLogs.removeFirst() + } + } + + allLogs.addAll(bufferedLogs) + bufferedLogs.clear() + } + + _uiState.update { it.copy(isPaused = !it.isPaused) } + updateDisplayedLogs() + } + + override fun onCleared() { + super.onCleared() + commandClient.disconnect() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt new file mode 100644 index 0000000000..e6be04e7ea --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/log/LogViewerViewModel.kt @@ -0,0 +1,25 @@ +package io.nekohasekai.sfa.compose.screen.log + +import io.nekohasekai.sfa.constant.Status +import kotlinx.coroutines.flow.StateFlow + +interface LogViewerViewModel { + val uiState: StateFlow + val scrollToBottomTrigger: StateFlow + val isAtBottom: StateFlow + + fun updateServiceStatus(status: Status) + fun togglePause() + fun toggleSearch() + fun toggleOptionsMenu() + fun updateSearchQuery(query: String) + fun setLogLevel(level: LogLevel) + fun setAutoScrollEnabled(enabled: Boolean) + fun scrollToBottom() + fun toggleSelectionMode() + fun toggleLogSelection(index: Int) + fun clearSelection() + fun getSelectedLogsText(): String + fun getAllLogsText(): String + fun requestClearLogs() +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt new file mode 100644 index 0000000000..8734b431ca --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/privilegesettings/PrivilegeSettingsManageScreen.kt @@ -0,0 +1,847 @@ +package io.nekohasekai.sfa.compose.screen.privilegesettings + +import android.content.pm.PackageManager +import android.os.Build +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.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.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Sort +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.LinearProgressIndicator +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.material3.TopAppBarDefaults +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +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.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.database.Settings +import io.nekohasekai.sfa.ktx.clipboardText +import io.nekohasekai.sfa.utils.PrivilegeSettingsClient +import io.nekohasekai.sfa.vendor.PackageQueryManager +import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.Locale + +private data class LoadResult(val packages: List, val selectedUids: Set) + +private const val VPN_SERVICE_PERMISSION = "android.permission.BIND_VPN_SERVICE" + +private val managementPermissions = + setOf( + "android.permission.CONTROL_VPN", + "android.permission.CONTROL_ALWAYS_ON_VPN", + "android.permission.MANAGE_VPN", + "android.permission.NETWORK_SETTINGS", + "android.permission.NETWORK_STACK", + "android.permission.MAINLINE_NETWORK_STACK", + "android.permission.CONNECTIVITY_INTERNAL", + "android.permission.NETWORK_MANAGEMENT", + "android.permission.TETHER_PRIVILEGED", + "android.permission.MANAGE_NETWORK_POLICY", + ) + +private enum class RiskCategory { + NONE, + VPN_APP, + MANAGEMENT_APP, + BOTH, +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivilegeSettingsManageScreen(onBack: () -> Unit) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val coroutineScope = rememberCoroutineScope() + + var sortMode by remember { mutableStateOf(SortMode.NAME) } + var sortReverse by remember { mutableStateOf(false) } + var hideSystemApps by remember { mutableStateOf(false) } + var hideOfflineApps by remember { mutableStateOf(true) } + var hideDisabledApps by remember { mutableStateOf(true) } + + var packages by remember { mutableStateOf>(emptyList()) } + var displayPackages by remember { mutableStateOf>(emptyList()) } + var currentPackages by remember { mutableStateOf>(emptyList()) } + var selectedUids by remember { mutableStateOf>(emptySet()) } + var isLoading by remember { mutableStateOf(true) } + + var isSearchActive by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var riskyWarningMessage by remember { mutableStateOf(null) } + var syncErrorMessage by remember { mutableStateOf(null) } + + fun getRiskCategory(packageCache: PackageCache): RiskCategory { + val permissions = packageCache.info.requestedPermissions ?: emptyArray() + val hasManagement = permissions.any { it in managementPermissions } + val isSelf = packageCache.packageName == context.packageName + val hasVpnService = + !isSelf && + ( + permissions.any { it == VPN_SERVICE_PERMISSION } || + packageCache.info.services?.any { it.permission == VPN_SERVICE_PERMISSION } == true + ) + return when { + hasManagement && hasVpnService -> RiskCategory.BOTH + hasManagement -> RiskCategory.MANAGEMENT_APP + hasVpnService -> RiskCategory.VPN_APP + else -> RiskCategory.NONE + } + } + + fun buildPackageList(newUids: Set): Set = newUids.mapNotNull { uid -> + packages.find { it.uid == uid }?.packageName + }.toSet() + + fun updateCurrentPackages(filterQuery: String) { + currentPackages = + if (filterQuery.isEmpty()) { + displayPackages + } else { + displayPackages.filter { + it.applicationLabel.contains(filterQuery, ignoreCase = true) || + it.packageName.contains(filterQuery, ignoreCase = true) || + it.uid.toString().contains(filterQuery) + } + } + } + + fun applyFilter() { + displayPackages = + buildDisplayPackages( + packages = packages, + selectedUids = selectedUids, + selectedFirst = true, + hideSystemApps = hideSystemApps, + hideOfflineApps = hideOfflineApps, + hideDisabledApps = hideDisabledApps, + sortMode = sortMode, + sortReverse = sortReverse, + ) + currentPackages = displayPackages + } + + fun saveSelectedApplications(newUids: Set) { + coroutineScope.launch { + val failure = + withContext(Dispatchers.IO) { + Settings.privilegeSettingsList = buildPackageList(newUids) + PrivilegeSettingsClient.sync() + } + if (failure != null) { + syncErrorMessage = failure.message ?: failure.toString() + } + } + } + + fun warnIfRiskySelected(newUids: Set) { + val addedUids = newUids - selectedUids + if (addedUids.isEmpty()) return + val addedApps = packages.filter { it.uid in addedUids } + val vpnUids = + addedApps + .filter { getRiskCategory(it) == RiskCategory.VPN_APP || getRiskCategory(it) == RiskCategory.BOTH } + .map { it.uid } + .toSet() + val managementUids = + addedApps + .filter { getRiskCategory(it) == RiskCategory.MANAGEMENT_APP || getRiskCategory(it) == RiskCategory.BOTH } + .map { it.uid } + .toSet() + val vpnApps = packages.filter { it.uid in vpnUids }.distinctBy { it.packageName } + val managementApps = packages.filter { it.uid in managementUids }.distinctBy { it.packageName } + if (vpnApps.isEmpty() && managementApps.isEmpty()) return + + val listSeparator = if (Locale.getDefault().language == "zh") "、" else ", " + val messages = ArrayList(2) + if (vpnApps.isNotEmpty()) { + val labelList = vpnApps.map { it.applicationLabel }.distinct().sorted() + val labels = labelList.joinToString(listSeparator) + messages += + if (labelList.size == 1) { + context.getString( + R.string.privilege_settings_risky_vpn_message_single, + labels, + ) + } else { + context.getString( + R.string.privilege_settings_risky_vpn_message_multi, + labels, + ) + } + } + if (managementApps.isNotEmpty()) { + val labelList = managementApps.map { it.applicationLabel }.distinct().sorted() + val labels = labelList.joinToString(listSeparator) + messages += + if (labelList.size == 1) { + context.getString( + R.string.privilege_settings_risky_management_message_single, + labels, + ) + } else { + context.getString( + R.string.privilege_settings_risky_management_message_multi, + labels, + ) + } + } + riskyWarningMessage = messages.joinToString("\n") + } + + fun postSaveSelectedApplications(newUids: Set, warnRisky: Boolean = true) { + if (warnRisky) { + warnIfRiskySelected(newUids) + } + selectedUids = newUids + saveSelectedApplications(newUids) + } + + fun toggleSelection(packageCache: PackageCache, selected: Boolean) { + val newSelected = + if (selected) { + selectedUids + packageCache.uid + } else { + selectedUids - packageCache.uid + } + if (newSelected == selectedUids) return + postSaveSelectedApplications(newSelected) + } + + LaunchedEffect(Unit) { + isLoading = true + val packageManagerFlags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES + } else { + @Suppress("DEPRECATION") + PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES + } + val loadResult = + withContext(Dispatchers.IO) { + try { + val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags, packageManagerFlags) + val packageManager = context.packageManager + val packageCaches = + installedPackages.mapNotNull { packageInfo -> + val appInfo = packageInfo.applicationInfo ?: return@mapNotNull null + PackageCache(packageInfo, appInfo, packageManager) + } + val selectedPackageNames = Settings.privilegeSettingsList.toMutableSet() + val selectedUidSet = + packageCaches.mapNotNull { packageCache -> + if (selectedPackageNames.contains(packageCache.packageName)) { + packageCache.uid + } else { + null + } + }.toSet() + LoadResult(packageCaches, selectedUidSet) + } catch (_: PrivilegedAccessRequiredException) { + null + } + } + if (loadResult == null) { + Toast.makeText( + context, + R.string.privileged_access_required, + Toast.LENGTH_LONG, + ).show() + onBack() + return@LaunchedEffect + } + packages = loadResult.packages + selectedUids = loadResult.selectedUids + applyFilter() + updateCurrentPackages(searchQuery) + isLoading = false + } + + if (riskyWarningMessage != null) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { riskyWarningMessage = null }, + title = { Text(stringResource(R.string.privilege_settings_risky_app_title)) }, + text = { Text(riskyWarningMessage ?: "") }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { riskyWarningMessage = null }, + ) { + Text(stringResource(R.string.ok)) + } + }, + ) + } + if (syncErrorMessage != null) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { syncErrorMessage = null }, + title = { Text(stringResource(R.string.error_title)) }, + text = { Text(syncErrorMessage ?: "") }, + confirmButton = { + androidx.compose.material3.TextButton( + onClick = { syncErrorMessage = null }, + ) { + Text(stringResource(R.string.ok)) + } + }, + ) + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.privilege_settings_hide_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + actions = { + IconButton( + onClick = { + isSearchActive = !isSearchActive + if (!isSearchActive) { + searchQuery = "" + updateCurrentPackages("") + focusManager.clearFocus() + } + }, + ) { + Icon( + imageVector = if (isSearchActive) Icons.Default.Close else Icons.Default.Search, + contentDescription = stringResource(R.string.search), + ) + } + PrivilegeSettingsMenus( + sortMode = sortMode, + sortReverse = sortReverse, + hideSystemApps = hideSystemApps, + hideOfflineApps = hideOfflineApps, + hideDisabledApps = hideDisabledApps, + onSortModeChange = { mode -> + sortMode = mode + applyFilter() + }, + onSortReverseToggle = { + sortReverse = !sortReverse + applyFilter() + }, + onHideSystemAppsToggle = { + hideSystemApps = !hideSystemApps + applyFilter() + }, + onHideOfflineAppsToggle = { + hideOfflineApps = !hideOfflineApps + applyFilter() + }, + onHideDisabledAppsToggle = { + hideDisabledApps = !hideDisabledApps + applyFilter() + }, + onSelectAll = { + val newSelected = currentPackages.map { it.uid }.toSet() + postSaveSelectedApplications(newSelected) + }, + onDeselectAll = { + postSaveSelectedApplications(emptySet()) + }, + onImport = { + val packageNames = + clipboardText?.split("\n")?.distinct() + ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() } + if (packageNames.isNullOrEmpty()) { + Toast.makeText( + context, + R.string.toast_clipboard_empty, + Toast.LENGTH_SHORT, + ).show() + } else { + val newSelected = + packages.mapNotNull { packageCache -> + if (packageNames.contains(packageCache.packageName)) { + packageCache.uid + } else { + null + } + }.toSet() + postSaveSelectedApplications(newSelected) + Toast.makeText( + context, + R.string.toast_imported_from_clipboard, + Toast.LENGTH_SHORT, + ).show() + } + }, + onExport = { + val packageList = + packages.mapNotNull { packageCache -> + if (selectedUids.contains(packageCache.uid)) { + packageCache.packageName + } else { + null + } + } + clipboardText = packageList.joinToString("\n") + Toast.makeText( + context, + R.string.toast_copied_to_clipboard, + Toast.LENGTH_SHORT, + ).show() + }, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + } + + Column( + modifier = Modifier.fillMaxSize(), + ) { + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + ) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Text( + text = stringResource(R.string.privilege_settings_hide_description), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + + AnimatedVisibility( + visible = isSearchActive, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + focusRequester.requestFocus() + } + } + + OutlinedTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + updateCurrentPackages(it) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.search)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search), + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { + searchQuery = "" + updateCurrentPackages("") + focusManager.clearFocus() + }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.content_description_clear_search), + ) + } + } + }, + singleLine = true, + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(currentPackages, key = { it.packageName }) { packageCache -> + AppSelectionCard( + packageCache = packageCache, + selected = selectedUids.contains(packageCache.uid), + onToggle = { selected -> toggleSelection(packageCache, selected) }, + onCopyLabel = { clipboardText = packageCache.applicationLabel }, + onCopyPackage = { clipboardText = packageCache.packageName }, + onCopyUid = { clipboardText = packageCache.uid.toString() }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PrivilegeSettingsMenus( + sortMode: SortMode, + sortReverse: Boolean, + hideSystemApps: Boolean, + hideOfflineApps: Boolean, + hideDisabledApps: Boolean, + onSortModeChange: (SortMode) -> Unit, + onSortReverseToggle: () -> Unit, + onHideSystemAppsToggle: () -> Unit, + onHideOfflineAppsToggle: () -> Unit, + onHideDisabledAppsToggle: () -> Unit, + onSelectAll: () -> Unit, + onDeselectAll: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit, +) { + var showMainMenu by remember { mutableStateOf(false) } + var showSortMenu by remember { mutableStateOf(false) } + var showFilterMenu by remember { mutableStateOf(false) } + var showSelectMenu by remember { mutableStateOf(false) } + var showBackupMenu by remember { mutableStateOf(false) } + + IconButton(onClick = { showMainMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + + DropdownMenu( + expanded = showMainMenu, + onDismissRequest = { + showMainMenu = false + showSortMenu = false + showFilterMenu = false + showSelectMenu = false + showBackupMenu = false + }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode)) }, + onClick = { showSortMenu = !showSortMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + if (showSortMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_name)) }, + onClick = { + onSortModeChange(SortMode.NAME) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_package_name)) }, + onClick = { + onSortModeChange(SortMode.PACKAGE_NAME) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_uid)) }, + onClick = { + onSortModeChange(SortMode.UID) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_install_time)) }, + onClick = { + onSortModeChange(SortMode.INSTALL_TIME) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_update_time)) }, + onClick = { + onSortModeChange(SortMode.UPDATE_TIME) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_reverse)) }, + onClick = { + onSortReverseToggle() + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + } + + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_filter)) }, + onClick = { showFilterMenu = !showFilterMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + if (showFilterMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_hide_system_apps)) }, + onClick = { + onHideSystemAppsToggle() + showMainMenu = false + showFilterMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_hide_offline_apps)) }, + onClick = { + onHideOfflineAppsToggle() + showMainMenu = false + showFilterMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_hide_disabled_apps)) }, + onClick = { + onHideDisabledAppsToggle() + showMainMenu = false + showFilterMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_select)) }, + onClick = { showSelectMenu = !showSelectMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + if (showSelectMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_select_all)) }, + onClick = { + onSelectAll() + showMainMenu = false + showSelectMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.action_deselect)) }, + onClick = { + onDeselectAll() + showMainMenu = false + showSelectMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_backup)) }, + onClick = { showBackupMenu = !showBackupMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + if (showBackupMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_import)) }, + onClick = { + onImport() + showMainMenu = false + showBackupMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_export)) }, + onClick = { + onExport() + showMainMenu = false + showBackupMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt new file mode 100644 index 0000000000..1c883d0feb --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentScreen.kt @@ -0,0 +1,863 @@ +package io.nekohasekai.sfa.compose.screen.profile + +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.imePadding +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.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Redo +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +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.material3.TopAppBarDefaults +import androidx.compose.material3.VerticalDivider +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.blacksquircle.ui.language.json.JsonLanguage +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +fun EditProfileContentScreen( + profileId: Long, + onNavigateBack: () -> Unit, + modifier: Modifier = Modifier, + profileName: String = "", + isReadOnly: Boolean = false, +) { + val viewModel: EditProfileContentViewModel = + viewModel( + factory = EditProfileContentViewModel.Factory(profileId, profileName, isReadOnly), + ) + val uiState by viewModel.uiState.collectAsState() + val context = LocalContext.current + var showUnsavedChangesDialog by remember { mutableStateOf(false) } + val searchFocusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val coroutineScope = rememberCoroutineScope() + + // Handle error messages + LaunchedEffect(uiState.errorMessage) { + uiState.errorMessage?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + viewModel.clearError() + } + } + + // Focus search field when search bar is shown + LaunchedEffect(uiState.showSearchBar) { + if (uiState.showSearchBar) { + searchFocusRequester.requestFocus() + } + } + + // Handle save success message + LaunchedEffect(uiState.showSaveSuccessMessage) { + if (uiState.showSaveSuccessMessage) { + Toast.makeText( + context, + context.getString(R.string.success_configuration_saved), + Toast.LENGTH_SHORT, + ).show() + viewModel.clearSaveSuccessMessage() + } + } + + // Handle back press when there are unsaved changes (not in read-only mode) + BackHandler(enabled = uiState.hasUnsavedChanges && !uiState.isReadOnly) { + showUnsavedChangesDialog = true + } + + OverrideTopBar { + TopAppBar( + title = { + Column { + Text( + if (uiState.isReadOnly) { + stringResource(R.string.view_configuration) + } else { + stringResource(R.string.title_edit_configuration) + }, + ) + if (uiState.profileName.isNotEmpty()) { + Text( + text = uiState.profileName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton( + onClick = { + if (uiState.hasUnsavedChanges && !uiState.isReadOnly) { + showUnsavedChangesDialog = true + } else { + onNavigateBack() + } + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + actions = { + // Search/Collapse button (Ctrl/Cmd+F) + IconButton( + onClick = { viewModel.toggleSearchBar() }, + ) { + Icon( + imageVector = if (uiState.showSearchBar) Icons.Default.ExpandLess else Icons.Default.Search, + contentDescription = + if (uiState.showSearchBar) { + stringResource(R.string.content_description_collapse_search) + } else { + stringResource(R.string.search) + }, + tint = + if (uiState.showSearchBar) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + + // Save button (only show if not read-only) (Ctrl/Cmd+S) + if (!uiState.isReadOnly) { + IconButton( + onClick = { viewModel.saveConfiguration() }, + enabled = uiState.hasUnsavedChanges && !uiState.isLoading, + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = stringResource(R.string.save), + tint = + if (uiState.hasUnsavedChanges) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + + Column( + modifier = + modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + // Support both Ctrl (Windows/Linux) and Cmd (macOS) + val modifierPressed = event.isCtrlPressed || event.isMetaPressed + + when { + // Ctrl/Cmd+Z - Undo + modifierPressed && event.key == Key.Z && !event.isShiftPressed && !uiState.isReadOnly -> { + viewModel.undo() + true + } + // Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y - Redo + ( + modifierPressed && + event.isShiftPressed && + event.key == Key.Z || + modifierPressed && + event.key == Key.Y + ) && + !uiState.isReadOnly -> { + viewModel.redo() + true + } + // Ctrl/Cmd+S - Save + modifierPressed && event.key == Key.S && !uiState.isReadOnly -> { + if (uiState.hasUnsavedChanges && !uiState.isLoading) { + viewModel.saveConfiguration() + } + true + } + // Ctrl/Cmd+F - Search + modifierPressed && event.key == Key.F -> { + viewModel.toggleSearchBar() + true + } + // Ctrl/Cmd+A - Select All + modifierPressed && event.key == Key.A -> { + viewModel.selectAll() + true + } + // Ctrl/Cmd+X - Cut (only in edit mode) + modifierPressed && event.key == Key.X && !uiState.isReadOnly -> { + viewModel.cut() + true + } + // Ctrl/Cmd+C - Copy + modifierPressed && event.key == Key.C -> { + viewModel.copy() + true + } + // Ctrl/Cmd+V - Paste (only in edit mode) + modifierPressed && event.key == Key.V && !uiState.isReadOnly -> { + viewModel.paste() + true + } + // Escape - Close search bar if open + event.key == Key.Escape && uiState.showSearchBar -> { + viewModel.toggleSearchBar() + true + } + // F3 or Ctrl/Cmd+G - Find next (when search is active) + (event.key == Key.F3 || (modifierPressed && event.key == Key.G && !event.isShiftPressed)) && + uiState.searchQuery.isNotEmpty() -> { + viewModel.findNext() + viewModel.focusEditor() + true + } + // Shift+F3 or Ctrl/Cmd+Shift+G - Find previous (when search is active) + ( + (event.isShiftPressed && event.key == Key.F3) || + (modifierPressed && event.isShiftPressed && event.key == Key.G) + ) && + uiState.searchQuery.isNotEmpty() -> { + viewModel.findPrevious() + viewModel.focusEditor() + true + } + + else -> false + } + } else { + false + } + }, + ) { + // Search bar (appears at top when activated) + AnimatedVisibility( + visible = uiState.showSearchBar, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 2.dp, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 12.dp, end = 12.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = uiState.searchQuery, + onValueChange = { viewModel.updateSearchQuery(it) }, + modifier = + Modifier + .weight(1f) + .focusRequester(searchFocusRequester) + .onPreviewKeyEvent { event -> + if (event.key == Key.Enter && event.type == KeyEventType.KeyDown) { + coroutineScope.launch { + // Clear focus from search field first + focusManager.clearFocus() + // Small delay to let UI update + delay(100) + // Then focus editor with current search result selection + viewModel.focusEditorWithCurrentSearchResult() + } + true + } else { + false + } + }, + label = { Text(stringResource(R.string.search)) }, + placeholder = { Text(stringResource(R.string.search_placeholder)) }, + singleLine = true, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (uiState.searchQuery.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = + if (uiState.searchResultCount > 0) { + "${uiState.currentSearchIndex}/${uiState.searchResultCount}" + } else { + "0/0" + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(end = 4.dp), + ) + IconButton( + onClick = { + // Focus editor with current selection before clearing search + viewModel.focusEditorWithCurrentSearchResult() + viewModel.updateSearchQuery("") + focusManager.clearFocus() + }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.clear), + modifier = Modifier.size(18.dp), + ) + } + } + } + }, + ) + + // Only show navigation buttons when there are search results + if (uiState.searchQuery.isNotEmpty() && uiState.searchResultCount > 0) { + Spacer(modifier = Modifier.width(8.dp)) + + IconButton( + onClick = { + viewModel.findPrevious() + viewModel.focusEditor() + }, + ) { + Icon( + imageVector = Icons.Default.ArrowUpward, + contentDescription = stringResource(R.string.previous), + tint = MaterialTheme.colorScheme.primary, + ) + } + + IconButton( + onClick = { + viewModel.findNext() + viewModel.focusEditor() + }, + ) { + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = stringResource(R.string.next), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + + // Editor in a Box with floating elements + Box( + modifier = + Modifier + .fillMaxSize() + .clipToBounds() + .weight(1f), + ) { + // Editor + AndroidView( + factory = { context -> + ManualScrollTextProcessor(context).apply { + language = JsonLanguage() + setTextSize(14f) + setPadding(16, 16, 16, if (uiState.isReadOnly) 16 else 120) // Less padding for read-only + typeface = android.graphics.Typeface.MONOSPACE + 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) + } + }, + update = { textProcessor -> + // Re-apply configuration when read-only state changes + viewModel.setEditor(textProcessor, uiState.isReadOnly) + }, + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) + + // Simple loading indicator at the top + if (uiState.isLoading) { + LinearProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.TopCenter), + ) + } + + // Floating bottom editor bar with error banner (only show if not read-only) + if (!uiState.isReadOnly) { + Column( + modifier = + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .imePadding(), + ) { + // Configuration error banner (appears above the symbol bar) + AnimatedVisibility( + visible = uiState.configurationError != null, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .padding(bottom = 2.dp), + shape = RoundedCornerShape(12.dp), + tonalElevation = 6.dp, + shadowElevation = 4.dp, + color = MaterialTheme.colorScheme.errorContainer, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp), + // Match symbol bar padding + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = uiState.configurationError ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + IconButton( + onClick = { viewModel.dismissConfigurationError() }, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.dismiss), + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + + // Symbol input bar + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + tonalElevation = 6.dp, + shadowElevation = 4.dp, + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Undo button with text + TextButton( + onClick = { viewModel.undo() }, + enabled = uiState.canUndo, + modifier = Modifier.padding(end = 4.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.Undo, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = + if (uiState.canUndo) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.menu_undo), + style = MaterialTheme.typography.labelLarge, + color = + if (uiState.canUndo) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Redo button with text + TextButton( + onClick = { viewModel.redo() }, + enabled = uiState.canRedo, + modifier = Modifier.padding(end = 4.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.Redo, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = + if (uiState.canRedo) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.menu_redo), + style = MaterialTheme.typography.labelLarge, + color = + if (uiState.canRedo) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Format button with text + TextButton( + onClick = { viewModel.formatConfiguration() }, + modifier = Modifier.padding(end = 8.dp), + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.menu_format), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + + VerticalDivider( + modifier = + Modifier + .height(24.dp) + .padding(horizontal = 8.dp), + ) + + // Symbols ranked by frequency of use in JSON + + // Most common - quotes and colon (used for every key-value pair) + TextButton( + onClick = { viewModel.insertSymbol("\"") }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp) + .width(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = "\"", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + TextButton( + onClick = { viewModel.insertSymbol(":") }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp) + .width(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = ":", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + TextButton( + onClick = { viewModel.insertSymbol(",") }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp) + .width(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = ",", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + // Object brackets (very common) + TextButton( + onClick = { viewModel.insertSymbol("{") }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp) + .width(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = "{", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + TextButton( + onClick = { viewModel.insertSymbol("}") }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp) + .width(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = "}", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + // Array brackets (common) + TextButton( + onClick = { viewModel.insertSymbol("[") }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp) + .width(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = "[", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + TextButton( + onClick = { viewModel.insertSymbol("]") }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp) + .width(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = "]", + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + + Spacer(modifier = Modifier.width(4.dp)) + + // Common values - using same TextButton style for keywords + listOf("true", "false").forEach { text -> + TextButton( + onClick = { viewModel.insertSymbol(text) }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + ) { + Text( + text = text, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(modifier = Modifier.width(4.dp)) + + // Less common symbols - same TextButton style + listOf("-", "_", "/", "\\", "(", ")", "@", "#", "$", "%", "&", "*").forEach { symbol -> + TextButton( + onClick = { viewModel.insertSymbol(symbol) }, + modifier = + Modifier + .padding(0.dp) + .height(36.dp) + .width(36.dp), + shape = RoundedCornerShape(4.dp), + contentPadding = PaddingValues(0.dp), + ) { + Text( + text = symbol, + fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + // End padding for scroll + Spacer(modifier = Modifier.width(8.dp)) + } + } + } + } + } + } + // Unsaved changes dialog + if (showUnsavedChangesDialog) { + AlertDialog( + onDismissRequest = { showUnsavedChangesDialog = false }, + title = { Text(stringResource(R.string.unsaved_changes)) }, + text = { Text(stringResource(R.string.unsaved_changes_message)) }, + confirmButton = { + TextButton( + onClick = { + showUnsavedChangesDialog = false + onNavigateBack() + }, + ) { + Text(stringResource(R.string.discard), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton( + onClick = { showUnsavedChangesDialog = false }, + ) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + // Initial loading + LaunchedEffect(profileId) { + viewModel.loadConfiguration() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt new file mode 100644 index 0000000000..1c19a9ba24 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileContentViewModel.kt @@ -0,0 +1,598 @@ +package io.nekohasekai.sfa.compose.screen.profile + +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.ktx.unwrap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +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, + val canRedo: Boolean = false, + val showSaveSuccessMessage: Boolean = false, + val errorMessage: String? = null, + val configurationError: String? = null, + val isCheckingConfig: Boolean = false, + val showSearchBar: Boolean = false, + val searchQuery: String = "", + val searchResultCount: Int = 0, + val currentSearchIndex: Int = 0, + val isReadOnly: Boolean = false, // Add read-only flag + val profileName: String = "", // Add profile name +) + +class EditProfileContentViewModel(private val profileId: Long, initialProfileName: String = "", initialIsReadOnly: Boolean = false) : ViewModel() { + private val _uiState = + MutableStateFlow( + EditProfileContentUiState( + profileName = initialProfileName, + isReadOnly = initialIsReadOnly, + ), + ) + val uiState: StateFlow = _uiState.asStateFlow() + + private var profile: Profile? = null + private var editor: ManualScrollTextProcessor? = null + private var configCheckJob: Job? = null + + fun setEditor(textProcessor: ManualScrollTextProcessor, isReadOnly: Boolean = false) { + val isNewEditor = editor != textProcessor + 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 + + 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 + } + } + } 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 + configCheckJob?.cancel() + + // Clear error immediately when user is typing + _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 + checkConfigurationInBackground(content) + } + } + + private suspend fun checkConfigurationInBackground(content: String) { + if (content.isBlank()) { + // Don't check empty content + return + } + + withContext(Dispatchers.IO) { + try { + _uiState.update { it.copy(isCheckingConfig = true) } + + // Check configuration + Libbox.checkConfig(content) + + // Configuration is valid, clear any error + _uiState.update { + it.copy( + configurationError = null, + isCheckingConfig = false, + ) + } + } catch (e: Exception) { + // Configuration has errors, show them + _uiState.update { + it.copy( + configurationError = e.message ?: "Invalid configuration", + isCheckingConfig = false, + ) + } + } + } + } + + fun loadConfiguration() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(isLoading = true) } + + try { + val loadedProfile = + ProfileManager.get(profileId) + ?: throw IllegalArgumentException("Profile not found") + profile = loadedProfile + + // Just load the content, we already have profile metadata from Intent + val content = File(loadedProfile.typed.path).readText() + + withContext(Dispatchers.Main) { + editor?.let { + it.resumeAutoScroll() + it.setTextContent(content) + } + _uiState.update { + it.copy( + content = content, + originalContent = content, + hasUnsavedChanges = false, + isLoading = false, + // Keep profileName and isReadOnly from initial state - no need to update + ) + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "Failed to load configuration", + ) + } + } + } + } + + fun saveConfiguration() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(isLoading = true) } + + try { + val currentContent = + withContext(Dispatchers.Main) { + editor?.text?.toString() ?: "" + } + + // Save to file without validation + profile?.let { p -> + File(p.typed.path).writeText(currentContent) + } + + _uiState.update { + it.copy( + isLoading = false, + originalContent = currentContent, + hasUnsavedChanges = false, + showSaveSuccessMessage = true, + ) + } + + // Hide success message after delay + delay(2000) + _uiState.update { it.copy(showSaveSuccessMessage = false) } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "Save failed", + ) + } + } + } + } + + fun formatConfiguration() { + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(isLoading = true) } + + try { + val currentContent = + withContext(Dispatchers.Main) { + editor?.text?.toString() ?: "" + } + val formatted = Libbox.formatConfig(currentContent).unwrap + + if (formatted != currentContent) { + withContext(Dispatchers.Main) { + editor?.let { + it.resumeAutoScroll() + it.setTextContent(formatted) + } + } + // Note: hasUnsavedChanges will be updated by the text change listener + } + + _uiState.update { it.copy(isLoading = false) } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message ?: "Format failed", + ) + } + } + } + } + + fun undo() { + editor?.let { + if (it.canUndo()) { + it.resumeAutoScroll() + it.undo() + _uiState.update { state -> + state.copy( + canUndo = it.canUndo(), + canRedo = it.canRedo(), + ) + } + } + } + } + + fun redo() { + editor?.let { + if (it.canRedo()) { + it.resumeAutoScroll() + it.redo() + _uiState.update { state -> + state.copy( + canUndo = it.canUndo(), + canRedo = it.canRedo(), + ) + } + } + } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + fun clearSaveSuccessMessage() { + _uiState.update { it.copy(showSaveSuccessMessage = false) } + } + + fun dismissConfigurationError() { + _uiState.update { it.copy(configurationError = null) } + } + + fun toggleSearchBar() { + _uiState.update { + val newShowSearchBar = !it.showSearchBar + it.copy( + showSearchBar = newShowSearchBar, + searchQuery = "", + searchResultCount = 0, + currentSearchIndex = 0, + ) + } + } + + fun updateSearchQuery(query: String) { + _uiState.update { it.copy(searchQuery = query) } + if (query.isNotEmpty()) { + performSearch(query) + } else { + _uiState.update { + it.copy( + searchResultCount = 0, + currentSearchIndex = 0, + ) + } + } + } + + private fun performSearch(query: String) { + editor?.let { textProcessor -> + val text = textProcessor.text?.toString() ?: "" + if (text.isEmpty() || query.isEmpty()) { + _uiState.update { + it.copy( + searchResultCount = 0, + currentSearchIndex = 0, + ) + } + return + } + + val matches = mutableListOf() + var index = text.indexOf(query, ignoreCase = true) + while (index != -1) { + matches.add(index) + index = text.indexOf(query, index + 1, ignoreCase = true) + } + + _uiState.update { + it.copy( + searchResultCount = matches.size, + currentSearchIndex = if (matches.isNotEmpty()) 1 else 0, + ) + } + + // Highlight first match + if (matches.isNotEmpty()) { + val firstMatch = matches[0] + textProcessor.resumeAutoScroll() + textProcessor.setSelection(firstMatch, firstMatch + query.length) + } + } + } + + fun findNext() { + val state = _uiState.value + if (state.searchResultCount == 0 || state.searchQuery.isEmpty()) return + + editor?.let { textProcessor -> + val text = textProcessor.text?.toString() ?: "" + val currentPosition = textProcessor.selectionEnd + + var nextIndex = text.indexOf(state.searchQuery, currentPosition, ignoreCase = true) + if (nextIndex == -1) { + // Wrap around to beginning + nextIndex = text.indexOf(state.searchQuery, 0, ignoreCase = true) + } + + if (nextIndex != -1) { + textProcessor.resumeAutoScroll() + textProcessor.setSelection(nextIndex, nextIndex + state.searchQuery.length) + + // Update current index + val matches = mutableListOf() + var index = text.indexOf(state.searchQuery, ignoreCase = true) + var currentMatchIndex = 0 + var counter = 0 + while (index != -1) { + if (index == nextIndex) { + currentMatchIndex = counter + 1 + } + matches.add(index) + counter++ + index = text.indexOf(state.searchQuery, index + 1, ignoreCase = true) + } + + _uiState.update { + it.copy(currentSearchIndex = currentMatchIndex) + } + } + } + } + + fun findPrevious() { + val state = _uiState.value + if (state.searchResultCount == 0 || state.searchQuery.isEmpty()) return + + editor?.let { textProcessor -> + val text = textProcessor.text?.toString() ?: "" + val currentPosition = textProcessor.selectionStart + + var prevIndex = text.lastIndexOf(state.searchQuery, currentPosition - 1, ignoreCase = true) + if (prevIndex == -1) { + // Wrap around to end + prevIndex = text.lastIndexOf(state.searchQuery, ignoreCase = true) + } + + if (prevIndex != -1) { + textProcessor.resumeAutoScroll() + textProcessor.setSelection(prevIndex, prevIndex + state.searchQuery.length) + + // Update current index + val matches = mutableListOf() + var index = text.indexOf(state.searchQuery, ignoreCase = true) + var currentMatchIndex = 0 + var counter = 0 + while (index != -1) { + if (index == prevIndex) { + currentMatchIndex = counter + 1 + } + matches.add(index) + counter++ + index = text.indexOf(state.searchQuery, index + 1, ignoreCase = true) + } + + _uiState.update { + it.copy(currentSearchIndex = currentMatchIndex) + } + } + } + } + + fun insertSymbol(symbol: String) { + editor?.let { textProcessor -> + val start = textProcessor.selectionStart + val end = textProcessor.selectionEnd + val text = textProcessor.text + + if (text != null) { + 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) + } + } + } + + fun focusEditor() { + editor?.let { textProcessor -> + // Ensure the editor is focusable + textProcessor.isFocusable = true + textProcessor.isFocusableInTouchMode = true + textProcessor.resumeAutoScroll() + textProcessor.requestFocus() + + // Keep the current selection if there's a search active + if (_uiState.value.searchQuery.isNotEmpty() && _uiState.value.searchResultCount > 0) { + // Selection is already set by search, just request focus + textProcessor.requestFocus() + } else if (!_uiState.value.isReadOnly) { + // No search active and not read-only, place cursor at current position + val currentPosition = textProcessor.selectionEnd + textProcessor.setSelection(currentPosition) + } + } + } + + fun focusEditorWithCurrentSearchResult() { + editor?.let { textProcessor -> + // Ensure the editor is focusable + textProcessor.isFocusable = true + textProcessor.isFocusableInTouchMode = true + textProcessor.resumeAutoScroll() + + val state = _uiState.value + if (state.searchQuery.isNotEmpty() && state.searchResultCount > 0) { + // Make sure current search result is selected + val text = textProcessor.text?.toString() ?: "" + val currentSelection = textProcessor.selectionStart + + // Find which match is currently selected or find the nearest one + var matchIndex = text.indexOf(state.searchQuery, currentSelection, ignoreCase = true) + if (matchIndex == -1 && currentSelection > 0) { + // Try from the beginning if no match found after cursor + matchIndex = text.indexOf(state.searchQuery, 0, ignoreCase = true) + } + + if (matchIndex != -1) { + textProcessor.setSelection(matchIndex, matchIndex + state.searchQuery.length) + } + } + textProcessor.requestFocus() + } + } + + fun selectAll() { + editor?.let { textProcessor -> + val text = textProcessor.text?.toString() ?: "" + if (text.isNotEmpty()) { + textProcessor.resumeAutoScroll() + textProcessor.setSelection(0, text.length) + textProcessor.requestFocus() + } + } + } + + fun cut() { + editor?.let { textProcessor -> + if (textProcessor.hasSelection()) { + textProcessor.onTextContextMenuItem(android.R.id.cut) + } + } + } + + fun copy() { + editor?.let { textProcessor -> + if (textProcessor.hasSelection()) { + textProcessor.onTextContextMenuItem(android.R.id.copy) + } + } + } + + fun paste() { + editor?.let { textProcessor -> + if (!_uiState.value.isReadOnly) { + textProcessor.onTextContextMenuItem(android.R.id.paste) + } + } + } + + class Factory( + private val profileId: Long, + private val initialProfileName: String = "", + private val initialIsReadOnly: Boolean = false, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(EditProfileContentViewModel::class.java)) { + return EditProfileContentViewModel(profileId, initialProfileName, initialIsReadOnly) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt new file mode 100644 index 0000000000..b617b4345d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileRoute.kt @@ -0,0 +1,181 @@ +package io.nekohasekai.sfa.compose.screen.profile + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument + +@Composable +fun EditProfileRoute(profileId: Long, onNavigateBack: () -> Unit, modifier: Modifier = Modifier) { + if (profileId == -1L) { + LaunchedEffect(Unit) { + onNavigateBack() + } + return + } + + val navController = rememberNavController() + val sharedViewModel: EditProfileViewModel = viewModel() + + LaunchedEffect(profileId) { + sharedViewModel.loadProfile(profileId) + } + + NavHost( + navController = navController, + startDestination = "edit_profile", + modifier = modifier, + ) { + composable( + route = "edit_profile", + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300), + ) + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300), + ) + }, + popEnterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300), + ) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300), + ) + }, + ) { + EditProfileScreen( + profileId = profileId, + onNavigateBack = onNavigateBack, + onNavigateToIconSelection = { currentIconId -> + navController.navigate("icon_selection/${currentIconId ?: "null"}") { + launchSingleTop = true + } + }, + onNavigateToEditContent = { profileName, isReadOnly -> + navController.navigate("edit_content/$profileName/$isReadOnly") { + launchSingleTop = true + } + }, + viewModel = sharedViewModel, + ) + } + + composable( + route = "icon_selection/{currentIconId}", + arguments = + listOf( + navArgument("currentIconId") { + type = NavType.StringType + nullable = true + }, + ), + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300), + ) + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300), + ) + }, + popEnterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300), + ) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300), + ) + }, + ) { backStackEntry -> + val currentIconId = + backStackEntry.arguments?.getString("currentIconId") + ?.takeIf { it != "null" } + + IconSelectionScreen( + currentIconId = currentIconId, + onIconSelected = { iconId -> + sharedViewModel.updateIcon(iconId) + navController.popBackStack("edit_profile", inclusive = false) + }, + onNavigateBack = { + navController.popBackStack("edit_profile", inclusive = false) + }, + ) + } + + composable( + route = "edit_content/{profileName}/{isReadOnly}", + arguments = + listOf( + navArgument("profileName") { + type = NavType.StringType + defaultValue = "" + }, + navArgument("isReadOnly") { + type = NavType.BoolType + defaultValue = false + }, + ), + enterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300), + ) + }, + exitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(300), + ) + }, + popEnterTransition = { + slideIntoContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300), + ) + }, + popExitTransition = { + slideOutOfContainer( + AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(300), + ) + }, + ) { backStackEntry -> + val profileName = backStackEntry.arguments?.getString("profileName") ?: "" + val isReadOnly = backStackEntry.arguments?.getBoolean("isReadOnly") ?: false + + EditProfileContentScreen( + profileId = profileId, + onNavigateBack = { + navController.popBackStack("edit_profile", inclusive = false) + }, + profileName = profileName, + isReadOnly = isReadOnly, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt new file mode 100644 index 0000000000..84af0e049e --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileScreen.kt @@ -0,0 +1,563 @@ +package io.nekohasekai.sfa.compose.screen.profile + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +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.InsertDriveFile +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Update +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +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.material3.TopAppBarDefaults +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.base.SelectableMessageDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.ProfileIcons +import io.nekohasekai.sfa.compose.util.RelativeTimeFormatter +import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary +import io.nekohasekai.sfa.database.TypedProfile + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditProfileScreen( + profileId: Long, + onNavigateBack: () -> Unit, + onNavigateToIconSelection: (currentIconId: String?) -> Unit = {}, + onNavigateToEditContent: (profileName: String, isReadOnly: Boolean) -> Unit = { _, _ -> }, + viewModel: EditProfileViewModel = viewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + // Clear success indicator after delay + LaunchedEffect(uiState.showUpdateSuccess) { + if (uiState.showUpdateSuccess) { + kotlinx.coroutines.delay(1500) + viewModel.clearUpdateSuccess() + } + } + + // Dialog states + var showErrorDialog by remember { mutableStateOf(false) } + var showUnsavedChangesDialog by remember { mutableStateOf(false) } + + // Launch icon selection screen when needed + if (uiState.showIconDialog) { + LaunchedEffect(Unit) { + viewModel.hideIconDialog() + onNavigateToIconSelection(uiState.icon) + } + } + + // Show error dialog when there's an error message + LaunchedEffect(uiState.errorMessage) { + if (uiState.errorMessage != null) { + showErrorDialog = true + } + } + + // Error dialog + if (showErrorDialog) { + SelectableMessageDialog( + title = stringResource(R.string.error_title), + message = uiState.errorMessage ?: "", + onDismiss = { + showErrorDialog = false + viewModel.clearError() + }, + ) + } + + // Unsaved changes dialog + if (showUnsavedChangesDialog) { + AlertDialog( + onDismissRequest = { showUnsavedChangesDialog = false }, + title = { Text(stringResource(R.string.unsaved_changes)) }, + text = { Text(stringResource(R.string.unsaved_changes_message)) }, + confirmButton = { + TextButton( + onClick = { + showUnsavedChangesDialog = false + onNavigateBack() + }, + ) { + Text( + stringResource(R.string.discard), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton( + onClick = { showUnsavedChangesDialog = false }, + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + // Handle back navigation + val handleBack = { + if (uiState.hasChanges) { + showUnsavedChangesDialog = true + } else { + onNavigateBack() + } + } + + // Intercept system back button + BackHandler(enabled = uiState.hasChanges) { + showUnsavedChangesDialog = true + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_edit_profile)) }, + navigationIcon = { + IconButton(onClick = handleBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + + val bottomInset = + with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val bottomBarPadding = + if (uiState.hasChanges) { + 88.dp + bottomInset + } else { + 0.dp + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + // Progress indicator at top (only for initial loading) + if (uiState.isLoading) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + } + + if (!uiState.isLoading) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .padding(bottom = bottomBarPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Basic Information Card + 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.basic_information), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + ) + + OutlinedTextField( + value = uiState.name, + onValueChange = viewModel::updateName, + label = { Text(stringResource(R.string.profile_name)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + HorizontalDivider( + modifier = Modifier.padding(vertical = 4.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + + // Icon selection with Material You style + Text( + text = stringResource(R.string.icon), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Surface( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { viewModel.showIconDialog() }, + 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), + ) { + // Display current icon + val currentIcon = + ProfileIcons.getIconById(uiState.icon) + ?: Icons.AutoMirrored.Filled.InsertDriveFile + + Icon( + imageVector = currentIcon, + contentDescription = stringResource(R.string.profile_icon), + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Text( + text = + uiState.icon?.let { iconId -> + MaterialIconsLibrary.getAllIcons() + .find { it.id == iconId }?.label + } ?: stringResource(R.string.default_text), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = stringResource(R.string.select_icon), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + // Remote Profile Options + if (uiState.profileType == TypedProfile.Type.Remote) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.Default.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(20.dp), + ) + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(R.string.remote_configuration), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + uiState.lastUpdated?.let { lastUpdated -> + Text( + text = + stringResource( + R.string.last_updated_format, + RelativeTimeFormatter.format( + context, + lastUpdated, + ), + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + // Update button in top-right corner + IconButton( + onClick = { viewModel.updateRemoteProfile() }, + enabled = !uiState.isUpdating && !uiState.showUpdateSuccess, + ) { + when { + uiState.isUpdating -> { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + uiState.showUpdateSuccess -> { + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.success), + tint = MaterialTheme.colorScheme.primary, + ) + } + else -> { + Icon( + Icons.Default.Update, + contentDescription = stringResource(R.string.profile_update), + tint = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + } + + OutlinedTextField( + value = uiState.remoteUrl, + onValueChange = viewModel::updateRemoteUrl, + label = { Text(stringResource(R.string.profile_url)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + HorizontalDivider() + + // Auto Update Toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.profile_auto_update), + style = MaterialTheme.typography.bodyLarge, + ) + Switch( + checked = uiState.autoUpdate, + onCheckedChange = viewModel::updateAutoUpdate, + ) + } + + AnimatedVisibility(visible = uiState.autoUpdate) { + OutlinedTextField( + value = uiState.autoUpdateInterval.toString(), + onValueChange = viewModel::updateAutoUpdateInterval, + label = { Text(stringResource(R.string.profile_auto_update_interval)) }, + supportingText = { + uiState.autoUpdateIntervalError?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + ) + } ?: Text(stringResource(R.string.profile_auto_update_interval_minimum_hint)) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + isError = uiState.autoUpdateIntervalError != null, + ) + } + } + } + } + + // Content Card (for both Local and Remote profiles) - placed at the end + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.InsertDriveFile, + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier.size(20.dp), + ) + Text( + text = stringResource(R.string.content), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + ) + } + + // JSON Editor/Viewer option + Surface( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { + onNavigateToEditContent( + uiState.name, + uiState.profileType == TypedProfile.Type.Remote, + ) + }, + 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), + ) { + Icon( + imageVector = Icons.Default.Code, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = + if (uiState.profileType == TypedProfile.Type.Remote) { + stringResource(R.string.json_viewer) + } else { + stringResource(R.string.json_editor) + }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + AnimatedVisibility( + visible = uiState.hasChanges, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + modifier = Modifier.align(Alignment.BottomCenter), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(16.dp), + ) { + Button( + onClick = { viewModel.saveChanges() }, + modifier = Modifier.fillMaxWidth(), + enabled = !uiState.isSaving && uiState.autoUpdateIntervalError == null, + ) { + if (uiState.isSaving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon( + Icons.Default.Save, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(stringResource(R.string.save)) + } + } + } + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt new file mode 100644 index 0000000000..57d8531e6a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/EditProfileViewModel.kt @@ -0,0 +1,371 @@ +package io.nekohasekai.sfa.compose.screen.profile + +import android.app.Application +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.UpdateProfileWork +import io.nekohasekai.sfa.database.Profile +import io.nekohasekai.sfa.database.ProfileManager +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.database.TypedProfile +import io.nekohasekai.sfa.utils.HTTPClient +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class EditProfileUiState( + val profile: Profile? = null, + val name: String = "", + val icon: String? = null, + val profileType: TypedProfile.Type? = null, + val remoteUrl: String = "", + val autoUpdate: Boolean = false, + val autoUpdateInterval: Int = 60, + val lastUpdated: Date? = null, + // Original values for change detection + val originalName: String = "", + val originalIcon: String? = null, + val originalRemoteUrl: String = "", + val originalAutoUpdate: Boolean = false, + val originalAutoUpdateInterval: Int = 60, + // State flags + val hasChanges: Boolean = false, + val isLoading: Boolean = true, + val isUpdating: Boolean = false, + val showUpdateSuccess: Boolean = false, + val isSaving: Boolean = false, + val errorMessage: String? = null, + val autoUpdateIntervalError: String? = null, + val showIconDialog: Boolean = false, +) + +class EditProfileViewModel(application: Application) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(EditProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Store the content to export when user selects a file location + var pendingExportContent: String? = null + var pendingExportFileName: String? = null + + fun loadProfile(profileId: Long) { + viewModelScope.launch(Dispatchers.IO) { + try { + val profile = ProfileManager.get(profileId) + if (profile == null) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = "Profile not found", + ) + } + return@launch + } + + val typedProfile = profile.typed + _uiState.update { + it.copy( + profile = profile, + name = profile.name, + originalName = profile.name, + icon = profile.icon, + originalIcon = profile.icon, + profileType = typedProfile.type, + remoteUrl = typedProfile.remoteURL, + originalRemoteUrl = typedProfile.remoteURL, + autoUpdate = typedProfile.autoUpdate, + originalAutoUpdate = typedProfile.autoUpdate, + autoUpdateInterval = typedProfile.autoUpdateInterval, + originalAutoUpdateInterval = typedProfile.autoUpdateInterval, + lastUpdated = typedProfile.lastUpdated, + isLoading = false, + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = e.message, + ) + } + } + } + } + + fun updateName(name: String) { + _uiState.update { state -> + state.copy( + name = name, + hasChanges = + checkHasChanges( + state.copy(name = name), + ), + ) + } + } + + fun updateIcon(icon: String?) { + _uiState.update { state -> + state.copy( + icon = icon, + hasChanges = + checkHasChanges( + state.copy(icon = icon), + ), + ) + } + } + + fun showIconDialog() { + _uiState.update { it.copy(showIconDialog = true) } + } + + fun hideIconDialog() { + _uiState.update { it.copy(showIconDialog = false) } + } + + fun updateRemoteUrl(url: String) { + _uiState.update { state -> + state.copy( + remoteUrl = url, + hasChanges = + checkHasChanges( + state.copy(remoteUrl = url), + ), + ) + } + } + + fun updateAutoUpdate(enabled: Boolean) { + _uiState.update { state -> + state.copy( + autoUpdate = enabled, + hasChanges = + checkHasChanges( + state.copy(autoUpdate = enabled), + ), + ) + } + } + + fun updateAutoUpdateInterval(interval: String) { + val intValue = interval.toIntOrNull() ?: 60 + val error = + when { + interval.isBlank() -> getApplication().getString(R.string.profile_input_required) + intValue < 15 -> getApplication().getString(R.string.profile_auto_update_interval_minimum_hint) + else -> null + } + + _uiState.update { state -> + state.copy( + autoUpdateInterval = intValue, + autoUpdateIntervalError = error, + hasChanges = + if (error == null) { + checkHasChanges(state.copy(autoUpdateInterval = intValue)) + } else { + state.hasChanges + }, + ) + } + } + + private fun checkHasChanges(state: EditProfileUiState): Boolean = state.name != state.originalName || + state.icon != state.originalIcon || + state.remoteUrl != state.originalRemoteUrl || + state.autoUpdate != state.originalAutoUpdate || + state.autoUpdateInterval != state.originalAutoUpdateInterval + + fun saveChanges() { + val state = _uiState.value + val profile = state.profile ?: return + + if (state.autoUpdateIntervalError != null) { + return + } + + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(isSaving = true) } + + try { + // Update profile object + profile.name = state.name + profile.icon = state.icon + profile.typed.remoteURL = state.remoteUrl + + // Handle auto-update changes + val autoUpdateChanged = state.autoUpdate != state.originalAutoUpdate + profile.typed.autoUpdate = state.autoUpdate + profile.typed.autoUpdateInterval = state.autoUpdateInterval + + // Save to database + ProfileManager.update(profile) + + // Reconfigure updater if auto-update was enabled + if (autoUpdateChanged && state.autoUpdate) { + UpdateProfileWork.reconfigureUpdater() + } + + // Update UI state with new original values + _uiState.update { + it.copy( + originalName = state.name, + originalIcon = state.icon, + originalRemoteUrl = state.remoteUrl, + originalAutoUpdate = state.autoUpdate, + originalAutoUpdateInterval = state.autoUpdateInterval, + hasChanges = false, + isSaving = false, + ) + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isSaving = false, + errorMessage = e.message, + ) + } + } + } + } + + fun updateRemoteProfile() { + val state = _uiState.value + val profile = state.profile ?: return + + if (profile.typed.type != TypedProfile.Type.Remote) return + + viewModelScope.launch(Dispatchers.IO) { + _uiState.update { it.copy(isUpdating = true) } + + try { + var selectedProfileUpdated = false + + // Fetch remote config + val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } + Libbox.checkConfig(content) + + // Check if content changed + val file = File(profile.typed.path) + if (!file.exists() || file.readText() != content) { + file.writeText(content) + if (profile.id == Settings.selectedProfile) { + selectedProfileUpdated = true + } + } + + // Update last updated time + profile.typed.lastUpdated = Date() + ProfileManager.update(profile) + + // Update UI state with success indicator + _uiState.update { + it.copy( + lastUpdated = profile.typed.lastUpdated, + isUpdating = false, + showUpdateSuccess = true, + ) + } + + // Reload service if needed + if (selectedProfileUpdated) { + try { + Libbox.newStandaloneCommandClient().serviceReload() + } catch (e: Exception) { + // Service reload errors are not critical + } + } + } catch (e: Exception) { + _uiState.update { + it.copy( + isUpdating = false, + errorMessage = e.message, + ) + } + } + } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } + + fun clearUpdateSuccess() { + _uiState.update { it.copy(showUpdateSuccess = false) } + } + + fun prepareExport(context: Context): String? { + val state = _uiState.value + val profile = state.profile ?: return null + + return try { + // Read the configuration file + val configFile = File(profile.typed.path) + if (!configFile.exists()) { + Toast.makeText(context, "Configuration file not found", Toast.LENGTH_SHORT).show() + return null + } + + val content = configFile.readText() + val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val fileName = "${profile.name.replace(" ", "_")}_$timestamp.json" + + // Store content for later when user picks location + pendingExportContent = content + pendingExportFileName = fileName + + fileName + } catch (e: Exception) { + Toast.makeText( + context, + context.getString( + io.nekohasekai.sfa.R.string.failed_read_configuration, + e.message, + ), + Toast.LENGTH_SHORT, + ).show() + null + } + } + + fun saveExportToUri(context: Context, uri: Uri) { + val content = pendingExportContent ?: return + + viewModelScope.launch(Dispatchers.IO) { + try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + outputStream.write(content.toByteArray()) + } + + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Configuration exported successfully", + Toast.LENGTH_SHORT, + ).show() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, "Export failed: ${e.message}", Toast.LENGTH_LONG).show() + } + } finally { + // Clear pending export data + pendingExportContent = null + pendingExportFileName = null + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt new file mode 100644 index 0000000000..cf507ae022 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionDialog.kt @@ -0,0 +1,184 @@ +package io.nekohasekai.sfa.compose.screen.profile + +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.util.ProfileIcon +import io.nekohasekai.sfa.compose.util.ProfileIcons + +@Composable +fun IconSelectionDialog(currentIconId: String?, onIconSelected: (String?) -> Unit, onDismiss: () -> Unit) { + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 500.dp), + shape = RoundedCornerShape(16.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.select_profile_icon), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.fillMaxWidth(), + ) + + LazyVerticalGrid( + columns = GridCells.Fixed(4), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + ) { + // Add option to remove custom icon (use default) + item { + IconOption( + icon = null, + label = stringResource(R.string.default_text), + isSelected = currentIconId == null, + onClick = { + onIconSelected(null) + onDismiss() + }, + ) + } + + items(ProfileIcons.availableIcons) { profileIcon -> + IconOption( + icon = profileIcon, + label = profileIcon.label, + isSelected = currentIconId == profileIcon.id, + onClick = { + onIconSelected(profileIcon.id) + onDismiss() + }, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.cancel)) + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IconOption(icon: ProfileIcon?, label: String, isSelected: Boolean, onClick: () -> Unit) { + Card( + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(8.dp)) + .clickable { onClick() }, + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + }, + ), + border = + if (isSelected) { + CardDefaults.outlinedCardBorder() + } else { + null + }, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (icon != null) { + Icon( + imageVector = icon.icon, + contentDescription = label, + modifier = Modifier.size(28.dp), + tint = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } else { + // Default icon indicator + Text( + text = stringResource(R.string.auto), + style = MaterialTheme.typography.bodyMedium, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt new file mode 100644 index 0000000000..9e37361103 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/IconSelectionScreen.kt @@ -0,0 +1,622 @@ +package io.nekohasekai.sfa.compose.screen.profile + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.Clear +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +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.material3.TopAppBarDefaults +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.compose.util.ProfileIcon +import io.nekohasekai.sfa.compose.util.icons.IconCategory +import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IconSelectionScreen(currentIconId: String?, onIconSelected: (String?) -> Unit, onNavigateBack: () -> Unit) { + var searchQuery by remember { mutableStateOf("") } + var selectedCategory by remember { mutableStateOf(null) } + var viewMode by remember { mutableStateOf(IconViewMode.CATEGORIES) } + var isSearchActive by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + + // Get icons based on current mode + val displayedIcons = + remember(searchQuery, selectedCategory, viewMode) { + when { + searchQuery.isNotEmpty() -> MaterialIconsLibrary.searchIcons(searchQuery) + selectedCategory != null -> { + MaterialIconsLibrary.categories + .find { it.name == selectedCategory } + ?.icons ?: emptyList() + } + viewMode == IconViewMode.ALL -> MaterialIconsLibrary.getAllIcons() + else -> emptyList() + } + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.select_icon)) }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + actions = { + IconButton( + onClick = { + isSearchActive = !isSearchActive + if (!isSearchActive) { + searchQuery = "" + viewMode = IconViewMode.CATEGORIES + selectedCategory = null + focusManager.clearFocus() + } + }, + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = + if (isSearchActive) { + stringResource(R.string.close_search) + } else { + stringResource( + R.string.search_icons, + ) + }, + tint = + if (isSearchActive) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + ) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } + + val currentIcon = + currentIconId?.let { id -> + MaterialIconsLibrary.getIconById(id)?.let { icon -> id to icon } + } + val bottomInset = + with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val bottomBarPadding = + if (currentIcon != null) { + 88.dp + bottomInset + } else { + 0.dp + } + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(bottom = bottomBarPadding), + ) { + // Show search bar with animation + AnimatedVisibility( + visible = isSearchActive, + enter = + expandVertically( + animationSpec = tween(300), + ) + + fadeIn( + animationSpec = tween(300), + ), + exit = + shrinkVertically( + animationSpec = tween(300), + ) + + fadeOut( + animationSpec = tween(300), + ), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 4.dp, + ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + OutlinedTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + if (it.isNotEmpty()) { + viewMode = IconViewMode.SEARCH + } else { + viewMode = IconViewMode.CATEGORIES + selectedCategory = null + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, bottom = 12.dp) + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.search_icons_placeholder)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search), + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { + searchQuery = "" + viewMode = IconViewMode.CATEGORIES + selectedCategory = null + }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.content_description_clear_search), + ) + } + } + }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = + KeyboardActions( + onSearch = { + focusManager.clearFocus() + }, + ), + ) + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + // View mode tabs (only show when not searching) + AnimatedVisibility(visible = searchQuery.isEmpty()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = viewMode == IconViewMode.CATEGORIES && selectedCategory == null, + onClick = { + viewMode = IconViewMode.CATEGORIES + selectedCategory = null + }, + label = { Text(stringResource(R.string.categories)) }, + leadingIcon = + if (viewMode == IconViewMode.CATEGORIES && selectedCategory == null) { + { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) } + } else { + null + }, + ) + + FilterChip( + selected = viewMode == IconViewMode.ALL, + onClick = { + viewMode = IconViewMode.ALL + selectedCategory = null + }, + label = { Text(stringResource(R.string.all_icons)) }, + leadingIcon = + if (viewMode == IconViewMode.ALL) { + { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(16.dp)) } + } else { + null + }, + ) + + FilterChip( + selected = currentIconId == null, + onClick = { + onIconSelected(null) + onNavigateBack() + }, + label = { Text(stringResource(R.string.default_text)) }, + leadingIcon = { + Icon(Icons.Default.RestartAlt, contentDescription = null, Modifier.size(16.dp)) + }, + ) + } + } + + // Back button when category is selected + AnimatedVisibility(visible = selectedCategory != null && searchQuery.isEmpty()) { + TextButton( + onClick = { + selectedCategory = null + viewMode = IconViewMode.CATEGORIES + }, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + modifier = Modifier.size(20.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.back_to_categories)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Main content area + Box( + modifier = + Modifier + .fillMaxWidth() + .weight(1f), + ) { + when { + // Search results + searchQuery.isNotEmpty() -> { + if (displayedIcons.isEmpty()) { + EmptySearchResult(searchQuery) + } else { + IconGrid( + icons = displayedIcons, + currentIconId = currentIconId, + onIconClick = { icon -> + onIconSelected(icon.id) + onNavigateBack() + }, + ) + } + } + // Category view + viewMode == IconViewMode.CATEGORIES && selectedCategory == null -> { + CategoryList( + categories = MaterialIconsLibrary.categories, + currentIconId = currentIconId, + onCategoryClick = { category -> + selectedCategory = category.name + }, + ) + } + // Icons in selected category or all icons + displayedIcons.isNotEmpty() -> { + Column { + selectedCategory?.let { + Text( + text = it, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + IconGrid( + icons = displayedIcons, + currentIconId = currentIconId, + onIconClick = { icon -> + onIconSelected(icon.id) + onNavigateBack() + }, + ) + } + } + } + } + } + } + + currentIcon?.let { (id, icon) -> + Card( + modifier = + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f), + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + val iconInfo = MaterialIconsLibrary.getAllIcons().find { it.id == id } + Text( + text = + stringResource( + R.string.current_icon_format, + iconInfo?.label ?: id, + ), + style = MaterialTheme.typography.bodyMedium, + ) + MaterialIconsLibrary.getCategoryForIcon(id)?.let { category -> + Text( + text = category, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + +@Composable +private fun CategoryList(categories: List, currentIconId: String?, onCategoryClick: (IconCategory) -> Unit) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(categories) { category -> + CategoryCard( + category = category, + hasSelectedIcon = category.icons.any { it.id == currentIconId }, + onClick = { onCategoryClick(category) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CategoryCard(category: IconCategory, hasSelectedIcon: Boolean, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = + if (hasSelectedIcon) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + }, + ), + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = category.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.icon_count_format, category.icons.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Preview first 3 icons + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + category.icons.take(3).forEach { icon -> + Icon( + imageVector = icon.icon, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun IconGrid(icons: List, currentIconId: String?, onIconClick: (ProfileIcon) -> Unit) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 72.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(icons) { icon -> + IconGridItem( + icon = icon, + isSelected = currentIconId == icon.id, + onClick = { onIconClick(icon) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun IconGridItem(icon: ProfileIcon, isSelected: Boolean, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f), + colors = + CardDefaults.cardColors( + containerColor = + if (isSelected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + }, + ), + border = + if (isSelected) { + CardDefaults.outlinedCardBorder() + } else { + null + }, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = icon.icon, + contentDescription = icon.label, + modifier = Modifier.size(28.dp), + tint = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = icon.label, + style = MaterialTheme.typography.labelSmall, + color = + if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun EmptySearchResult(query: String) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(32.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Default.SearchOff, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.no_icons_found), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.no_icons_match, query), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +private enum class IconViewMode { + CATEGORIES, + ALL, + SEARCH, +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java new file mode 100644 index 0000000000..ad1ead99f7 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profile/ManualScrollTextProcessor.java @@ -0,0 +1,132 @@ +package io.nekohasekai.sfa.compose.screen.profile; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import com.blacksquircle.ui.editorkit.widget.TextProcessor; + +public class ManualScrollTextProcessor extends TextProcessor { + + private final int touchSlop; + private boolean allowCursorAutoScroll = true; + private float downX; + private float downY; + private boolean userDragging; + private int downSelectionStart = -1; + private int downSelectionEnd = -1; + private boolean restoringSelection; + + public ManualScrollTextProcessor(Context context) { + this(context, null); + } + + public ManualScrollTextProcessor(Context context, AttributeSet attrs) { + this(context, attrs, android.R.attr.autoCompleteTextViewStyle); + } + + public ManualScrollTextProcessor(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + } + + public void resumeAutoScroll() { + allowCursorAutoScroll = true; + userDragging = false; + } + + @Override + public boolean bringPointIntoView(int offset) { + if (allowCursorAutoScroll) { + return super.bringPointIntoView(offset); + } + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + downX = event.getX(); + downY = event.getY(); + userDragging = false; + restoringSelection = false; + downSelectionStart = getSelectionStart(); + downSelectionEnd = getSelectionEnd(); + break; + case MotionEvent.ACTION_MOVE: + if (!userDragging) { + float dx = Math.abs(event.getX() - downX); + float dy = Math.abs(event.getY() - downY); + if (dx > touchSlop || dy > touchSlop) { + userDragging = true; + allowCursorAutoScroll = false; + } + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + break; + default: + break; + } + + boolean handled = super.onTouchEvent(event); + + switch (action) { + case MotionEvent.ACTION_MOVE: + if (userDragging) { + maybeRestoreSelection(); + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (userDragging) { + maybeRestoreSelection(); + } else { + resumeAutoScroll(); + } + break; + default: + break; + } + + return handled; + } + + private void maybeRestoreSelection() { + if (userDragging && !restoringSelection) { + int selStart = getSelectionStart(); + int selEnd = getSelectionEnd(); + if (selStart != downSelectionStart || selEnd != downSelectionEnd) { + restoringSelection = true; + int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart; + setSelection(downSelectionStart, targetEnd); + } + } + } + + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + if (restoringSelection) { + restoringSelection = false; + super.onSelectionChanged(selStart, selEnd); + return; + } + + if (userDragging) { + if (downSelectionStart >= 0 + && (selStart != downSelectionStart || selEnd != downSelectionEnd)) { + restoringSelection = true; + int targetEnd = downSelectionEnd >= 0 ? downSelectionEnd : downSelectionStart; + setSelection(downSelectionStart, targetEnd); + return; + } + } + + downSelectionStart = selStart; + downSelectionEnd = selEnd; + super.onSelectionChanged(selStart, selEnd); + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt new file mode 100644 index 0000000000..74b89866b2 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/profileoverride/PerAppProxyScreen.kt @@ -0,0 +1,1390 @@ +package io.nekohasekai.sfa.compose.screen.profileoverride + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.heightIn +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.rememberScrollState +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.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.ManageSearch +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Sort +import androidx.compose.material.icons.filled.Tune +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.LinearProgressIndicator +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.material3.TopAppBarDefaults +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +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.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.database.Settings +import io.nekohasekai.sfa.ktx.clipboardText +import io.nekohasekai.sfa.vendor.PackageQueryManager +import io.nekohasekai.sfa.vendor.PrivilegedAccessRequiredException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import java.util.zip.ZipFile + +private data class LoadResult(val proxyMode: Int, val packages: List, val selectedUids: Set) + +private data class ScanProgress(val current: Int, val max: Int) + +private sealed class ScanResult { + data object Empty : ScanResult() + data class Found(val apps: Map) : ScanResult() +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PerAppProxyScreen(onBack: () -> Unit) { + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val coroutineScope = rememberCoroutineScope() + + var proxyMode by remember { mutableStateOf(Settings.perAppProxyMode) } + var sortMode by remember { mutableStateOf(SortMode.NAME) } + var sortReverse by remember { mutableStateOf(false) } + var hideSystemApps by remember { mutableStateOf(false) } + var hideOfflineApps by remember { mutableStateOf(true) } + var hideDisabledApps by remember { mutableStateOf(true) } + + var packages by remember { mutableStateOf>(emptyList()) } + var displayPackages by remember { mutableStateOf>(emptyList()) } + var currentPackages by remember { mutableStateOf>(emptyList()) } + var selectedUids by remember { mutableStateOf>(emptySet()) } + var isLoading by remember { mutableStateOf(true) } + + var isSearchActive by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + + var scanProgress by remember { mutableStateOf(null) } + var scanResult by remember { mutableStateOf(null) } + + fun buildPackageList(newUids: Set): Set = newUids.mapNotNull { uid -> + packages.find { it.uid == uid }?.packageName + }.toSet() + + fun updateCurrentPackages(filterQuery: String) { + currentPackages = + if (filterQuery.isEmpty()) { + displayPackages + } else { + displayPackages.filter { + it.applicationLabel.contains(filterQuery, ignoreCase = true) || + it.packageName.contains(filterQuery, ignoreCase = true) || + it.uid.toString().contains(filterQuery) + } + } + } + + fun applyFilter() { + displayPackages = + buildDisplayPackages( + packages = packages, + selectedUids = selectedUids, + selectedFirst = true, + hideSystemApps = hideSystemApps, + hideOfflineApps = hideOfflineApps, + hideDisabledApps = hideDisabledApps, + sortMode = sortMode, + sortReverse = sortReverse, + ) + currentPackages = displayPackages + } + + fun saveSelectedApplications(newUids: Set) { + coroutineScope.launch { + Settings.perAppProxyList = buildPackageList(newUids) + } + } + + fun postSaveSelectedApplications(newUids: Set) { + selectedUids = newUids + saveSelectedApplications(newUids) + } + + fun toggleSelection(packageCache: PackageCache, selected: Boolean) { + val newSelected = + if (selected) { + selectedUids + packageCache.uid + } else { + selectedUids - packageCache.uid + } + if (newSelected == selectedUids) return + selectedUids = newSelected + saveSelectedApplications(newSelected) + } + + fun startScan() { + if (scanProgress != null) return + val scanPackages = currentPackages.toList() + if (scanPackages.isEmpty()) return + scanProgress = ScanProgress(0, scanPackages.size) + coroutineScope.launch { + val startTime = System.currentTimeMillis() + val foundApps = + withContext(Dispatchers.Default) { + mutableMapOf().also { found -> + val progressInt = AtomicInteger() + scanPackages.map { packageCache -> + async { + if (PerAppProxyScanner.scanChinaPackage(packageCache.info)) { + found[packageCache.packageName] = packageCache + } + val nextValue = progressInt.incrementAndGet() + withContext(Dispatchers.Main) { + scanProgress = ScanProgress(nextValue, scanPackages.size) + } + } + }.awaitAll() + } + } + Log.d( + "PerAppProxyScanner", + "Scan China apps took ${(System.currentTimeMillis() - startTime).toDouble() / 1000}s", + ) + scanProgress = null + scanResult = if (foundApps.isEmpty()) ScanResult.Empty else ScanResult.Found(foundApps) + } + } + + LaunchedEffect(Unit) { + isLoading = true + val packageManagerFlags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } else { + @Suppress("DEPRECATION") + PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } + val retryFlags = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS + } else { + @Suppress("DEPRECATION") + PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS + } + val loadResult = + withContext(Dispatchers.IO) { + try { + val mode = + if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { + Settings.PER_APP_PROXY_INCLUDE + } else { + Settings.PER_APP_PROXY_EXCLUDE + } + val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags, retryFlags) + val packageManager = context.packageManager + val packageCaches = + installedPackages.mapNotNull { packageInfo -> + if (packageInfo.packageName == context.packageName) return@mapNotNull null + val appInfo = packageInfo.applicationInfo ?: return@mapNotNull null + PackageCache(packageInfo, appInfo, packageManager) + } + val selectedPackageNames = Settings.perAppProxyList.toMutableSet() + val selectedUidSet = + packageCaches.mapNotNull { packageCache -> + if (selectedPackageNames.contains(packageCache.packageName)) { + packageCache.uid + } else { + null + } + }.toSet() + LoadResult(mode, packageCaches, selectedUidSet) + } catch (_: PrivilegedAccessRequiredException) { + null + } + } + if (loadResult == null) { + Toast.makeText( + context, + R.string.privileged_access_required, + Toast.LENGTH_LONG, + ).show() + onBack() + return@LaunchedEffect + } + proxyMode = loadResult.proxyMode + packages = loadResult.packages + selectedUids = loadResult.selectedUids + applyFilter() + updateCurrentPackages(searchQuery) + isLoading = false + } + + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.per_app_proxy)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + actions = { + IconButton( + onClick = { + isSearchActive = !isSearchActive + if (!isSearchActive) { + searchQuery = "" + updateCurrentPackages("") + focusManager.clearFocus() + } + }, + ) { + Icon( + imageVector = if (isSearchActive) Icons.Default.Close else Icons.Default.Search, + contentDescription = stringResource(R.string.search), + ) + } + PerAppProxyMenus( + proxyMode = proxyMode, + sortMode = sortMode, + sortReverse = sortReverse, + hideSystemApps = hideSystemApps, + hideOfflineApps = hideOfflineApps, + hideDisabledApps = hideDisabledApps, + onModeChange = { mode -> + proxyMode = mode + coroutineScope.launch { + Settings.perAppProxyMode = mode + } + }, + onSortModeChange = { mode -> + sortMode = mode + applyFilter() + }, + onSortReverseToggle = { + sortReverse = !sortReverse + applyFilter() + }, + onHideSystemAppsToggle = { + hideSystemApps = !hideSystemApps + applyFilter() + }, + onHideOfflineAppsToggle = { + hideOfflineApps = !hideOfflineApps + applyFilter() + }, + onHideDisabledAppsToggle = { + hideDisabledApps = !hideDisabledApps + applyFilter() + }, + onSelectAll = { + val newSelected = currentPackages.map { it.uid }.toSet() + postSaveSelectedApplications(newSelected) + }, + onDeselectAll = { + postSaveSelectedApplications(emptySet()) + }, + onImport = { + val packageNames = + clipboardText?.split("\n")?.distinct() + ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() } + if (packageNames.isNullOrEmpty()) { + Toast.makeText( + context, + R.string.toast_clipboard_empty, + Toast.LENGTH_SHORT, + ).show() + } else { + val newSelected = + packages.mapNotNull { packageCache -> + if (packageNames.contains(packageCache.packageName)) { + packageCache.uid + } else { + null + } + }.toSet() + postSaveSelectedApplications(newSelected) + Toast.makeText( + context, + R.string.toast_imported_from_clipboard, + Toast.LENGTH_SHORT, + ).show() + } + }, + onExport = { + val packageList = + packages.mapNotNull { packageCache -> + if (selectedUids.contains(packageCache.uid)) { + packageCache.packageName + } else { + null + } + } + clipboardText = packageList.joinToString("\n") + Toast.makeText( + context, + R.string.toast_copied_to_clipboard, + Toast.LENGTH_SHORT, + ).show() + }, + onScanChinaApps = { startScan() }, + ) + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) + } + + Column( + modifier = Modifier.fillMaxSize(), + ) { + AnimatedVisibility( + visible = isLoading, + enter = fadeIn(), + exit = fadeOut(), + ) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Text( + text = + if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { + stringResource(R.string.per_app_proxy_mode_include_description) + } else { + stringResource(R.string.per_app_proxy_mode_exclude_description) + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + + AnimatedVisibility( + visible = isSearchActive, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(isSearchActive) { + if (isSearchActive) { + focusRequester.requestFocus() + } + } + + OutlinedTextField( + value = searchQuery, + onValueChange = { + searchQuery = it + updateCurrentPackages(it) + }, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .focusRequester(focusRequester), + placeholder = { Text(stringResource(R.string.search)) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = stringResource(R.string.search), + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { + searchQuery = "" + updateCurrentPackages("") + focusManager.clearFocus() + }) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(R.string.content_description_clear_search), + ) + } + } + }, + singleLine = true, + ) + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(currentPackages, key = { it.packageName }) { packageCache -> + AppSelectionCard( + packageCache = packageCache, + selected = selectedUids.contains(packageCache.uid), + onToggle = { selected -> toggleSelection(packageCache, selected) }, + onCopyLabel = { clipboardText = packageCache.applicationLabel }, + onCopyPackage = { clipboardText = packageCache.packageName }, + onCopyUid = { clipboardText = packageCache.uid.toString() }, + ) + } + } + } + + if (scanProgress != null) { + val progress = scanProgress + Dialog( + onDismissRequest = {}, + properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false), + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.message_scanning), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { + if (progress == null || progress.max == 0) { + 0f + } else { + progress.current.toFloat() / progress.max.toFloat() + } + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(8.dp)) + if (progress != null) { + Text( + text = "${progress.current}/${progress.max}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + when (val result = scanResult) { + ScanResult.Empty -> { + Dialog( + onDismissRequest = { scanResult = null }, + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.title_scan_result), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.message_scan_app_no_apps_found), + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { scanResult = null }) { + Text(stringResource(R.string.ok)) + } + } + } + } + } + } + + is ScanResult.Found -> { + val dialogContent = + stringResource(R.string.message_scan_app_found) + "\n\n" + + result.apps.entries.joinToString("\n") { + "${it.value.applicationLabel} (${it.key})" + } + Dialog( + onDismissRequest = { scanResult = null }, + ) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surface, + ) { + Column(modifier = Modifier.padding(24.dp)) { + Text( + text = stringResource(R.string.title_scan_result), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = + Modifier + .fillMaxWidth() + .heightIn(max = 360.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = dialogContent, + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { scanResult = null }) { + Text(stringResource(android.R.string.cancel)) + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton( + onClick = { + val newSelected = selectedUids.toMutableSet() + result.apps.values.forEach { + newSelected.remove(it.uid) + } + postSaveSelectedApplications(newSelected) + scanResult = null + }, + ) { + Text(stringResource(R.string.action_deselect)) + } + Spacer(modifier = Modifier.size(8.dp)) + TextButton( + onClick = { + val newSelected = selectedUids.toMutableSet() + result.apps.values.forEach { + newSelected.add(it.uid) + } + postSaveSelectedApplications(newSelected) + scanResult = null + }, + ) { + Text(stringResource(R.string.per_app_proxy_select)) + } + } + } + } + } + } + + null -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PerAppProxyMenus( + proxyMode: Int, + sortMode: SortMode, + sortReverse: Boolean, + hideSystemApps: Boolean, + hideOfflineApps: Boolean, + hideDisabledApps: Boolean, + onModeChange: (Int) -> Unit, + onSortModeChange: (SortMode) -> Unit, + onSortReverseToggle: () -> Unit, + onHideSystemAppsToggle: () -> Unit, + onHideOfflineAppsToggle: () -> Unit, + onHideDisabledAppsToggle: () -> Unit, + onSelectAll: () -> Unit, + onDeselectAll: () -> Unit, + onImport: () -> Unit, + onExport: () -> Unit, + onScanChinaApps: () -> Unit, +) { + var showMainMenu by remember { mutableStateOf(false) } + var showModeMenu by remember { mutableStateOf(false) } + var showSortMenu by remember { mutableStateOf(false) } + var showFilterMenu by remember { mutableStateOf(false) } + var showSelectMenu by remember { mutableStateOf(false) } + var showBackupMenu by remember { mutableStateOf(false) } + var showScanMenu by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { showMainMenu = true }) { + Icon(Icons.Default.MoreVert, contentDescription = null) + } + + DropdownMenu( + expanded = showMainMenu, + onDismissRequest = { + showMainMenu = false + showModeMenu = false + showSortMenu = false + showFilterMenu = false + showSelectMenu = false + showBackupMenu = false + showScanMenu = false + }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_mode)) }, + onClick = { showModeMenu = !showModeMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showModeMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showModeMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_mode_include)) }, + onClick = { + onModeChange(Settings.PER_APP_PROXY_INCLUDE) + showMainMenu = false + showModeMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_mode_exclude)) }, + onClick = { + onModeChange(Settings.PER_APP_PROXY_EXCLUDE) + showMainMenu = false + showModeMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (proxyMode == Settings.PER_APP_PROXY_EXCLUDE) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode)) }, + onClick = { showSortMenu = !showSortMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Sort, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showSortMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showSortMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_name)) }, + onClick = { + onSortModeChange(SortMode.NAME) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (sortMode == SortMode.NAME) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.NAME) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_package_name)) }, + onClick = { + onSortModeChange(SortMode.PACKAGE_NAME) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (sortMode == SortMode.PACKAGE_NAME) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.PACKAGE_NAME) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_uid)) }, + onClick = { + onSortModeChange(SortMode.UID) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (sortMode == SortMode.UID) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.UID) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_install_time)) }, + onClick = { + onSortModeChange(SortMode.INSTALL_TIME) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (sortMode == SortMode.INSTALL_TIME) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.INSTALL_TIME) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_update_time)) }, + onClick = { + onSortModeChange(SortMode.UPDATE_TIME) + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (sortMode == SortMode.UPDATE_TIME) { + Icons.Default.RadioButtonChecked + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortMode == SortMode.UPDATE_TIME) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_sort_mode_reverse)) }, + onClick = { + onSortReverseToggle() + showMainMenu = false + showSortMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (sortReverse) { + Icons.Default.Check + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (sortReverse) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_filter)) }, + onClick = { showFilterMenu = !showFilterMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showFilterMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showFilterMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_hide_system_apps)) }, + onClick = { + onHideSystemAppsToggle() + showMainMenu = false + showFilterMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (hideSystemApps) { + Icons.Default.Check + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (hideSystemApps) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_hide_offline_apps)) }, + onClick = { + onHideOfflineAppsToggle() + showMainMenu = false + showFilterMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (hideOfflineApps) { + Icons.Default.Check + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (hideOfflineApps) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_hide_disabled_apps)) }, + onClick = { + onHideDisabledAppsToggle() + showMainMenu = false + showFilterMenu = false + }, + leadingIcon = { + Icon( + imageVector = + if (hideDisabledApps) { + Icons.Default.Check + } else { + Icons.Default.RadioButtonUnchecked + }, + contentDescription = null, + tint = + if (hideDisabledApps) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_select)) }, + onClick = { showSelectMenu = !showSelectMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showSelectMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showSelectMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_select_all)) }, + onClick = { + onSelectAll() + showMainMenu = false + showSelectMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_select_none)) }, + onClick = { + onDeselectAll() + showMainMenu = false + showSelectMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.RadioButtonUnchecked, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_backup)) }, + onClick = { showBackupMenu = !showBackupMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Save, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showBackupMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showBackupMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_import)) }, + onClick = { + onImport() + showMainMenu = false + showBackupMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentPaste, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_export)) }, + onClick = { + onExport() + showMainMenu = false + showBackupMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_scan)) }, + onClick = { showScanMenu = !showScanMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ManageSearch, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showScanMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showScanMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_scan_china_apps)) }, + onClick = { + onScanChinaApps() + showMainMenu = false + showScanMenu = false + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ManageSearch, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + } + } + } +} + +object PerAppProxyScanner { + private val skipPrefixList = + listOf( + "com.google", + "com.android.chrome", + "com.android.vending", + "com.microsoft", + "com.apple", + "com.zhiliaoapp.musically", // Banned by China + "com.android.providers.downloads", + ) + + private val chinaAppPrefixList = + listOf( + "com.tencent", + "com.alibaba", + "com.umeng", + "com.qihoo", + "com.ali", + "com.alipay", + "com.amap", + "com.sina", + "com.weibo", + "com.vivo", + "com.xiaomi", + "com.huawei", + "com.taobao", + "com.secneo", + "s.h.e.l.l", + "com.stub", + "com.kiwisec", + "com.secshell", + "com.wrapper", + "cn.securitystack", + "com.mogosec", + "com.secoen", + "com.netease", + "com.mx", + "com.qq.e", + "com.baidu", + "com.bytedance", + "com.bugly", + "com.miui", + "com.oppo", + "com.coloros", + "com.iqoo", + "com.meizu", + "com.gionee", + "cn.nubia", + "com.oplus", + "andes.oplus", + "com.unionpay", + "cn.wps", + ) + + private val chinaAppRegex by lazy { + ("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex() + } + + suspend fun scanAllChinaApps(): Set = withContext(Dispatchers.Default) { + val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } else { + @Suppress("DEPRECATION") + PackageManager.GET_UNINSTALLED_PACKAGES or + PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or + PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS + } + val retryFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS + } else { + @Suppress("DEPRECATION") + PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_PERMISSIONS + } + val installedPackages = PackageQueryManager.getInstalledPackages(packageManagerFlags, retryFlags) + val chinaApps = mutableSetOf() + installedPackages.map { packageInfo -> + async { + if (scanChinaPackage(packageInfo)) { + synchronized(chinaApps) { + chinaApps.add(packageInfo.packageName) + } + } + } + }.awaitAll() + chinaApps.toSet() + } + + fun scanChinaPackage(packageInfo: PackageInfo): Boolean { + val packageName = packageInfo.packageName + if (packageName == Application.application.packageName) return false + skipPrefixList.forEach { + if (packageName == it || packageName.startsWith("$it.")) return false + } + + if (packageName.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match package name: $packageName") + return true + } + try { + val appInfo = packageInfo.applicationInfo ?: return false + packageInfo.services?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match service ${it.name} in $packageName") + return true + } + } + packageInfo.activities?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match activity ${it.name} in $packageName") + return true + } + } + packageInfo.receivers?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match receiver ${it.name} in $packageName") + return true + } + } + packageInfo.providers?.forEach { + if (it.name.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match provider ${it.name} in $packageName") + return true + } + } + ZipFile(File(appInfo.publicSourceDir)).use { + for (packageEntry in it.entries()) { + if (packageEntry.name.startsWith("firebase-")) return false + } + for (packageEntry in it.entries()) { + if (!( + packageEntry.name.startsWith("classes") && + packageEntry.name.endsWith(".dex") + ) + ) { + continue + } + if (packageEntry.size > 15000000) { + Log.d( + "PerAppProxyScanner", + "Confirm $packageName due to large dex file", + ) + return true + } + val input = it.getInputStream(packageEntry).buffered() + val dexFile = + try { + DexBackedDexFile.fromInputStream(null, input) + } catch (e: Exception) { + Log.e("PerAppProxyScanner", "Error reading dex file", e) + return false + } + for (clazz in dexFile.classes) { + val clazzName = + clazz.type.substring(1, clazz.type.length - 1).replace("/", ".") + .replace("$", ".") + if (clazzName.matches(chinaAppRegex)) { + Log.d("PerAppProxyScanner", "Match $clazzName in $packageName") + return true + } + } + } + } + } catch (e: Exception) { + Log.e("PerAppProxyScanner", "Error scanning package $packageName", e) + } + return false + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt new file mode 100644 index 0000000000..d9a5de0601 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRCodeSmartCrop.kt @@ -0,0 +1,268 @@ +package io.nekohasekai.sfa.compose.screen.qrscan + +import kotlin.math.max +import kotlin.math.min + +data class QRCodeCropArea( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + val imageWidth: Int, + val imageHeight: Int, + val rotationDegrees: Int, +) + +object QRCodeSmartCrop { + fun findCropArea(yData: ByteArray, width: Int, height: Int, rotationDegrees: Int): QRCodeCropArea? { + val minDim = min(width, height) + if (minDim <= 0) return null + + val step = (minDim / 120).coerceIn(4, 16) + val samplesWide = (width + step - 1) / step + val samplesHigh = (height + step - 1) / step + val sampleCount = samplesWide * samplesHigh + if (sampleCount == 0) return null + + val histogram = IntArray(256) + var sum = 0 + var maxLuma = 0 + var sy = 0 + var y = 0 + while (sy < samplesHigh) { + val rowOffset = y * width + var sx = 0 + var x = 0 + while (sx < samplesWide) { + val value = yData[rowOffset + x].toInt() and 0xFF + sum += value + histogram[value]++ + if (value > maxLuma) maxLuma = value + sx++ + x += step + } + sy++ + y += step + } + + val mean = sum / sampleCount + val contrast = maxLuma - mean + if (contrast < 30) return null + + val p95 = percentile(histogram, sampleCount, 0.95f) + val p90 = percentile(histogram, sampleCount, 0.90f) + val p85 = percentile(histogram, sampleCount, 0.85f) + + val thresholds = intArrayOf( + max((mean + contrast * 0.75f).toInt(), p95), + max((mean + contrast * 0.6f).toInt(), p90), + max((mean + contrast * 0.5f).toInt(), p85), + ) + + val minBrightSamples = max(12, sampleCount / 300) + var bestArea: QRCodeCropArea? = null + var bestRatio = 1f + + for (i in thresholds.indices) { + val threshold = thresholds[i].coerceAtMost(250) + val component = findBestComponent( + yData, + width, + height, + step, + samplesWide, + samplesHigh, + threshold, + minBrightSamples, + ) ?: continue + + val area = buildCropArea(component, step, width, height, rotationDegrees) ?: continue + val areaRatio = ((area.right - area.left) * (area.bottom - area.top)).toFloat() / (width * height) + val maxRatio = if (i == thresholds.lastIndex) 0.9f else 0.82f + if (areaRatio <= maxRatio) return area + if (areaRatio < bestRatio) { + bestRatio = areaRatio + bestArea = area + } + } + + return bestArea + } + + private data class CropComponent(val minX: Int, val minY: Int, val maxX: Int, val maxY: Int, val count: Int, val score: Float) + + private fun findBestComponent( + yData: ByteArray, + width: Int, + height: Int, + step: Int, + samplesWide: Int, + samplesHigh: Int, + threshold: Int, + minBrightSamples: Int, + ): CropComponent? { + val totalSamples = samplesWide * samplesHigh + val bright = BooleanArray(totalSamples) + var brightCount = 0 + + var sy = 0 + var y = 0 + while (sy < samplesHigh) { + val rowOffset = y * width + var sx = 0 + var x = 0 + while (sx < samplesWide) { + val value = yData[rowOffset + x].toInt() and 0xFF + if (value >= threshold) { + bright[sy * samplesWide + sx] = true + brightCount++ + } + sx++ + x += step + } + sy++ + y += step + } + + if (brightCount < minBrightSamples) return null + + val visited = BooleanArray(totalSamples) + val queue = IntArray(totalSamples) + val minComponentSamples = max(8, minBrightSamples / 3) + val centerX = width / 2f + val centerY = height / 2f + val maxDistSq = centerX * centerX + centerY * centerY + + var best: CropComponent? = null + for (cy in 0 until samplesHigh) { + for (cx in 0 until samplesWide) { + val index = cy * samplesWide + cx + if (!bright[index] || visited[index]) continue + + var head = 0 + var tail = 0 + queue[tail++] = index + visited[index] = true + + var count = 0 + var minX = cx + var maxX = cx + var minY = cy + var maxY = cy + + while (head < tail) { + val current = queue[head++] + val x = current % samplesWide + val yIndex = current / samplesWide + count++ + + if (x < minX) minX = x + if (x > maxX) maxX = x + if (yIndex < minY) minY = yIndex + if (yIndex > maxY) maxY = yIndex + + val startX = if (x > 0) x - 1 else x + val endX = if (x + 1 < samplesWide) x + 1 else x + val startY = if (yIndex > 0) yIndex - 1 else yIndex + val endY = if (yIndex + 1 < samplesHigh) yIndex + 1 else yIndex + + var ny = startY + while (ny <= endY) { + val rowIndex = ny * samplesWide + var nx = startX + while (nx <= endX) { + if (nx != x || ny != yIndex) { + val neighbor = rowIndex + nx + if (bright[neighbor] && !visited[neighbor]) { + visited[neighbor] = true + queue[tail++] = neighbor + } + } + nx++ + } + ny++ + } + } + + if (count < minComponentSamples) continue + + val compWidth = maxX - minX + 1 + val compHeight = maxY - minY + 1 + val aspect = max(compWidth.toFloat() / compHeight, compHeight.toFloat() / compWidth) + val aspectPenalty = ((aspect - 1f) / 1.6f).coerceIn(0f, 1f) + val compCenterX = (minX + maxX + 1) * 0.5f * step + val compCenterY = (minY + maxY + 1) * 0.5f * step + val dx = compCenterX - centerX + val dy = compCenterY - centerY + val normDist = if (maxDistSq > 0f) (dx * dx + dy * dy) / maxDistSq else 0f + val edgeTouches = (if (minX == 0) 1 else 0) + + (if (minY == 0) 1 else 0) + + (if (maxX == samplesWide - 1) 1 else 0) + + (if (maxY == samplesHigh - 1) 1 else 0) + + var score = count.toFloat() + score *= 1f - 0.5f * normDist.coerceIn(0f, 1f) + score *= 1f - 0.35f * aspectPenalty + score *= 1f - 0.15f * edgeTouches + + if (best == null || score > best!!.score) { + best = CropComponent( + minX = minX, + minY = minY, + maxX = maxX, + maxY = maxY, + count = count, + score = score, + ) + } + } + } + + return best + } + + private fun buildCropArea(component: CropComponent, step: Int, width: Int, height: Int, rotationDegrees: Int): QRCodeCropArea? { + val left = component.minX * step + val top = component.minY * step + val right = min(width, (component.maxX + 1) * step) + val bottom = min(height, (component.maxY + 1) * step) + val cropWidth = right - left + val cropHeight = bottom - top + if (cropWidth <= 0 || cropHeight <= 0) return null + + val frameArea = width * height + val cropArea = cropWidth * cropHeight + if (cropArea < frameArea / 96) return null + + val aspect = cropWidth.toFloat() / cropHeight + if (aspect < 0.45f || aspect > 2.2f) return null + + val padding = (max(cropWidth, cropHeight) * 0.14f).toInt().coerceAtLeast(step * 2) + val cropLeft = (left - padding).coerceAtLeast(0) + val cropTop = (top - padding).coerceAtLeast(0) + val cropRight = (right + padding).coerceAtMost(width) + val cropBottom = (bottom + padding).coerceAtMost(height) + if (cropRight <= cropLeft || cropBottom <= cropTop) return null + + return QRCodeCropArea( + left = cropLeft, + top = cropTop, + right = cropRight, + bottom = cropBottom, + imageWidth = width, + imageHeight = height, + rotationDegrees = rotationDegrees, + ) + } + + private fun percentile(histogram: IntArray, count: Int, percentile: Float): Int { + if (count <= 0) return 0 + val target = (count * percentile).toInt().coerceIn(0, count - 1) + var acc = 0 + for (i in histogram.indices) { + acc += histogram[i] + if (acc > target) return i + } + return histogram.lastIndex + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt new file mode 100644 index 0000000000..7c1d1ec547 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/QRScanViewModel.kt @@ -0,0 +1,403 @@ +package io.nekohasekai.sfa.compose.screen.qrscan + +import android.app.Application +import android.net.Uri +import android.util.Base64 +import android.util.Log +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LifecycleOwner +import io.nekohasekai.libbox.Libbox +import io.nekohasekai.sfa.qrs.QRSDecoder +import io.nekohasekai.sfa.qrs.readIntLE +import io.nekohasekai.sfa.vendor.Vendor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean + +sealed class QRScanResult { + data class RemoteProfile(val uri: Uri) : QRScanResult() + data class QRSData(val data: ByteArray) : QRScanResult() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as QRSData + return data.contentEquals(other.data) + } + + override fun hashCode(): Int = data.contentHashCode() + } +} + +data class QRScanUiState( + val isLoading: Boolean = true, + val useFrontCamera: Boolean = false, + val torchEnabled: Boolean = false, + val useVendorAnalyzer: Boolean = true, + val vendorAnalyzerAvailable: Boolean = false, + val qrsMode: Boolean = false, + val qrsProgress: Pair? = null, + val cropArea: QRCodeCropArea? = null, + val errorMessage: String? = null, + val result: QRScanResult? = null, + val zoomRatio: Float = 1f, + val maxZoomRatio: Float = 1f, +) + +class QRScanViewModel(application: Application) : AndroidViewModel(application) { + companion object { + private const val TAG = "QRScanViewModel" + } + + private val _uiState = MutableStateFlow(QRScanUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val analysisExecutor: ExecutorService = Executors.newSingleThreadExecutor() + private var cameraProvider: ProcessCameraProvider? = null + private var camera: Camera? = null + private var imageAnalysis: ImageAnalysis? = null + private var imageAnalyzer: ImageAnalysis.Analyzer? = null + private var cameraPreview: Preview? = null + + private var qrsDecoder: QRSDecoder? = null + private val showingError = AtomicBoolean(false) + private val qrsLock = Any() + + private val vendorAnalyzer: ImageAnalysis.Analyzer? = Vendor.createQRCodeAnalyzer( + onSuccess = { rawValue -> handleScanSuccess(rawValue) }, + onFailure = { exception -> handleScanFailure(exception) }, + onCropArea = ::updateCropArea, + ) + + init { + _uiState.update { + it.copy( + vendorAnalyzerAvailable = vendorAnalyzer != null, + useVendorAnalyzer = vendorAnalyzer != null, + ) + } + } + + private val onSuccess: (String) -> Unit = { rawValue: String -> + handleScanSuccess(rawValue) + } + + private val onFailure: (Exception) -> Unit = { exception -> + handleScanFailure(exception) + } + + private fun updateCropArea(area: QRCodeCropArea?) { + _uiState.update { state -> + if (state.cropArea == area) { + state + } else { + state.copy(cropArea = area) + } + } + } + + private fun handleScanSuccess(rawValue: String) { + Log.d(TAG, "Scanned: ${rawValue.take(100)}...") + val qrsPayload = extractQRSPayload(rawValue) + Log.d(TAG, "extractQRSPayload result: ${qrsPayload?.size ?: "null"}") + if (qrsPayload != null) { + handleQRSFrame(qrsPayload) + } else { + updateCropArea(null) + if (_uiState.value.qrsMode) { + resetQRSState() + } + imageAnalysis?.clearAnalyzer() + processQRCode(rawValue) + } + } + + private fun handleScanFailure(exception: Exception) { + if (_uiState.value.qrsMode) { + return + } + updateCropArea(null) + imageAnalysis?.clearAnalyzer() + if (showingError.compareAndSet(false, true)) { + resetAnalyzer() + _uiState.update { it.copy(errorMessage = exception.message) } + } + } + + private fun resetAnalyzer() { + if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) { + _uiState.update { it.copy(useVendorAnalyzer = false) } + imageAnalysis?.clearAnalyzer() + imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea) + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + } + } + + fun startCamera(lifecycleOwner: LifecycleOwner, previewView: PreviewView) { + val context = getApplication() + val cameraProviderFuture = try { + ProcessCameraProvider.getInstance(context) + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } + return + } + + cameraProviderFuture.addListener({ + try { + cameraProvider = cameraProviderFuture.get() + + cameraPreview = Preview.Builder().build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + + imageAnalysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + + imageAnalyzer = if (_uiState.value.useVendorAnalyzer && vendorAnalyzer != null) { + vendorAnalyzer + } else { + ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea) + } + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + + bindCamera(lifecycleOwner) + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } + } + }, ContextCompat.getMainExecutor(context)) + } + + private fun bindCamera(lifecycleOwner: LifecycleOwner) { + val provider = cameraProvider ?: return + val preview = cameraPreview ?: return + val analysis = imageAnalysis ?: return + + provider.unbindAll() + + val cameraSelector = if (_uiState.value.useFrontCamera) { + CameraSelector.DEFAULT_FRONT_CAMERA + } else { + CameraSelector.DEFAULT_BACK_CAMERA + } + + try { + camera = provider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + preview, + analysis, + ) + val maxZoom = camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 1f + _uiState.update { it.copy(maxZoomRatio = maxZoom, zoomRatio = 1f) } + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = e.message, isLoading = false) } + } + } + + fun onPreviewStreamStateChanged(isStreaming: Boolean) { + if (isStreaming) { + _uiState.update { it.copy(isLoading = false) } + } + } + + fun toggleFrontCamera(lifecycleOwner: LifecycleOwner) { + _uiState.update { it.copy(useFrontCamera = !it.useFrontCamera) } + bindCamera(lifecycleOwner) + } + + fun toggleTorch() { + val newTorchState = !_uiState.value.torchEnabled + camera?.cameraControl?.enableTorch(newTorchState) + _uiState.update { it.copy(torchEnabled = newTorchState) } + } + + fun setZoomRatio(ratio: Float) { + val clampedRatio = ratio.coerceIn(1f, _uiState.value.maxZoomRatio) + camera?.cameraControl?.setZoomRatio(clampedRatio) + _uiState.update { it.copy(zoomRatio = clampedRatio) } + } + + fun toggleVendorAnalyzer() { + if (vendorAnalyzer == null) return + + val newState = !_uiState.value.useVendorAnalyzer + _uiState.update { it.copy(useVendorAnalyzer = newState) } + updateCropArea(null) + + imageAnalysis?.clearAnalyzer() + imageAnalyzer = if (newState) { + vendorAnalyzer + } else { + ZxingQRCodeAnalyzer(onSuccess, onFailure, onCropArea = ::updateCropArea) + } + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + } + + fun dismissError() { + showingError.set(false) + _uiState.update { it.copy(errorMessage = null) } + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + } + + fun clearResult() { + resetQRSState() + _uiState.update { it.copy(result = null, cropArea = null) } + } + + private fun extractQRSPayload(content: String): ByteArray? { + val base64Data = when { + content.startsWith("http") && content.contains("#") -> { + content.substring(content.indexOf('#') + 1) + } + else -> content + } + + val decoded = try { + Base64.decode(base64Data, Base64.DEFAULT) + } catch (e: Exception) { + Log.d(TAG, "Base64 decode failed: ${e.message}") + return null + } + + Log.d(TAG, "Decoded size: ${decoded.size}") + if (decoded.size < 20) { + Log.d(TAG, "Too small: ${decoded.size} < 20") + return null + } + + val degree = decoded.readIntLE(0) + Log.d(TAG, "degree: $degree") + if (degree <= 0 || degree > 1000) { + Log.d(TAG, "Invalid degree: $degree") + return null + } + + val headerSize = 4 + 4 * degree + 12 + if (decoded.size < headerSize) { + Log.d(TAG, "Too small for header: ${decoded.size} < $headerSize") + return null + } + + val k = decoded.readIntLE(4 + 4 * degree) + Log.d(TAG, "k: $k") + if (k <= 0 || k > 100000) { + Log.d(TAG, "Invalid k: $k") + return null + } + + Log.d(TAG, "Valid QRS block detected!") + return decoded + } + + private fun handleQRSFrame(payload: ByteArray) = synchronized(qrsLock) { + Log.d(TAG, "Processing QRS frame") + if (qrsDecoder == null) { + qrsDecoder = QRSDecoder() + _uiState.update { it.copy(qrsMode = true) } + (imageAnalyzer as? ZxingQRCodeAnalyzer)?.qrsMode = true + Log.d(TAG, "Created new QRSDecoder, entered QRS mode") + } + + val progress = qrsDecoder!!.processFrame(payload) + Log.d(TAG, "processFrame result: $progress") + if (progress == null) { + Log.d(TAG, "processFrame returned null!") + return@synchronized + } + + _uiState.update { + it.copy(qrsProgress = Pair(progress.decodedBlocks, progress.totalBlocks)) + } + + if (progress.isComplete) { + if (progress.error != null) { + Log.e(TAG, "QRS complete with error: ${progress.error}, retrying...") + resetQRSState() + } else if (progress.data != null) { + imageAnalysis?.clearAnalyzer() + Log.d(TAG, "QRS complete! Data size: ${progress.data.size}") + importQRSProfile(progress.data) + } + } + } + + fun resetQRSState() = synchronized(qrsLock) { + qrsDecoder?.reset() + qrsDecoder = null + (imageAnalyzer as? ZxingQRCodeAnalyzer)?.qrsMode = false + _uiState.update { it.copy(qrsMode = false, qrsProgress = null) } + } + + private fun parseQRSFileFormat(data: ByteArray): ByteArray { + var offset = 0 + + val metaLength = ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) + offset += 4 + + offset += metaLength + + val dataLength = ((data[offset].toInt() and 0xFF) shl 24) or + ((data[offset + 1].toInt() and 0xFF) shl 16) or + ((data[offset + 2].toInt() and 0xFF) shl 8) or + (data[offset + 3].toInt() and 0xFF) + offset += 4 + + return data.copyOfRange(offset, offset + dataLength) + } + + private fun importQRSProfile(data: ByteArray) { + try { + val actualData = try { + parseQRSFileFormat(data) + } catch (e: Exception) { + Log.d(TAG, "Not official QRS format, using raw data") + data + } + Log.d(TAG, "Decoding profile content, size: ${actualData.size}") + Libbox.decodeProfileContent(actualData) + _uiState.update { it.copy(result = QRScanResult.QRSData(actualData)) } + } catch (e: Exception) { + _uiState.update { it.copy(errorMessage = e.message) } + resetQRSState() + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + } + } + + private fun processQRCode(value: String): Boolean { + try { + val uri = Uri.parse(value) + if (uri.scheme != "sing-box" || uri.host != "import-remote-profile") { + _uiState.update { it.copy(errorMessage = "Not a valid sing-box remote profile URI") } + imageAnalysis?.setAnalyzer(analysisExecutor, imageAnalyzer!!) + return false + } + Libbox.parseRemoteProfileImportLink(uri.toString()) + _uiState.update { it.copy(result = QRScanResult.RemoteProfile(uri)) } + return true + } catch (e: Exception) { + if (showingError.compareAndSet(false, true)) { + _uiState.update { it.copy(errorMessage = e.message) } + } + } + return false + } + + override fun onCleared() { + super.onCleared() + analysisExecutor.shutdown() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt new file mode 100644 index 0000000000..f79eb9cf98 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/qrscan/ZxingQRCodeAnalyzer.kt @@ -0,0 +1,118 @@ +package io.nekohasekai.sfa.compose.screen.qrscan + +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.zxing.BinaryBitmap +import com.google.zxing.ChecksumException +import com.google.zxing.FormatException +import com.google.zxing.NotFoundException +import com.google.zxing.PlanarYUVLuminanceSource +import com.google.zxing.Result +import com.google.zxing.common.GlobalHistogramBinarizer +import com.google.zxing.common.HybridBinarizer +import com.google.zxing.qrcode.QRCodeReader + +class ZxingQRCodeAnalyzer( + private val onSuccess: ((String) -> Unit), + private val onFailure: ((Exception) -> Unit), + private val onCropArea: ((QRCodeCropArea?) -> Unit)? = null, +) : ImageAnalysis.Analyzer { + private val qrCodeReader = QRCodeReader() + private var yDataBuffer: ByteArray? = null + + var qrsMode: Boolean = false + + override fun analyze(image: ImageProxy) { + try { + val yData = image.toYUVData() + val width = image.width + val height = image.height + val rotationDegrees = image.imageInfo.rotationDegrees + val source = PlanarYUVLuminanceSource(yData, width, height, 0, 0, width, height, false) + + // Fast path: HybridBinarizer + tryDecode(BinaryBitmap(HybridBinarizer(source)))?.let { + onSuccess(it.text) + return + } + + val cropArea = QRCodeSmartCrop.findCropArea(yData, width, height, rotationDegrees) + onCropArea?.invoke(cropArea) + if (cropArea != null) { + val cropWidth = cropArea.right - cropArea.left + val cropHeight = cropArea.bottom - cropArea.top + val smartSource = PlanarYUVLuminanceSource( + yData, + width, + height, + cropArea.left, + cropArea.top, + cropWidth, + cropHeight, + false, + ) + tryDecode(BinaryBitmap(HybridBinarizer(smartSource)))?.let { + onSuccess(it.text) + return + } + } + + // In QRS mode, skip additional binarizer attempts for performance + if (qrsMode) return + + // Inverted HybridBinarizer (uses ZXing's native invert) + tryDecode(BinaryBitmap(HybridBinarizer(source.invert())))?.let { + onSuccess(it.text) + return + } + + // GlobalHistogramBinarizer (normal) + tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source)))?.let { + onSuccess(it.text) + return + } + + // GlobalHistogramBinarizer (inverted) + tryDecode(BinaryBitmap(GlobalHistogramBinarizer(source.invert())))?.let { + onSuccess(it.text) + return + } + } catch (e: NotFoundException) { + // No QR code found in frame, ignore + } catch (e: ChecksumException) { + // Checksum error, ignore + } catch (e: FormatException) { + // Format error, ignore + } catch (e: Exception) { + onFailure(e) + } finally { + qrCodeReader.reset() + image.close() + } + } + + private fun ImageProxy.toYUVData(): ByteArray { + val yPlane = planes[0] + val yBuffer = yPlane.buffer + val rowStride = yPlane.rowStride + val size = width * height + + val yData = yDataBuffer?.takeIf { it.size >= size } ?: ByteArray(size).also { yDataBuffer = it } + if (rowStride == width) { + yBuffer.get(yData, 0, size) + } else { + for (row in 0 until height) { + yBuffer.position(row * rowStride) + yBuffer.get(yData, row * width, width) + } + } + return yData + } + + private fun tryDecode(bitmap: BinaryBitmap): Result? = try { + qrCodeReader.decode(bitmap) + } catch (_: NotFoundException) { + qrCodeReader.reset() + null + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt new file mode 100644 index 0000000000..8aa6d21868 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/AppSettingsScreen.kt @@ -0,0 +1,1194 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.app.LocaleConfig +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.appcompat.app.AppCompatDelegate +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.layout.size +import androidx.compose.foundation.layout.width +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.outlined.AdminPanelSettings +import androidx.compose.material.icons.outlined.Autorenew +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.NewReleases +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Speed +import androidx.compose.material.icons.outlined.SystemUpdateAlt +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.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.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.unit.dp +import androidx.core.os.LocaleListCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.navigation.NavController +import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.compose.component.UpdateAvailableDialog +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateCheckException +import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.update.UpdateTrack +import io.nekohasekai.sfa.utils.HookStatusClient +import io.nekohasekai.sfa.vendor.Vendor +import io.nekohasekai.sfa.xposed.XposedActivation +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.xmlpull.v1.XmlPullParser +import java.util.Locale +import android.provider.Settings as AndroidSettings + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppSettingsScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_app_settings)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + val hasUpdate by UpdateState.hasUpdate + val updateInfo by UpdateState.updateInfo + val isChecking by UpdateState.isChecking + var showTrackDialog by remember { mutableStateOf(false) } + var currentTrack by remember { mutableStateOf(Settings.updateTrack) } + var checkUpdateEnabled by remember { mutableStateOf(Settings.checkUpdateEnabled) } + var showErrorDialog by remember { mutableStateOf(null) } + + var silentInstallEnabled by remember { mutableStateOf(Settings.silentInstallEnabled) } + var silentInstallMethod by remember { mutableStateOf(Settings.silentInstallMethod) } + val systemHookStatus by HookStatusClient.status.collectAsState() + val xposedActivated = systemHookStatus?.active == true || XposedActivation.isActivated(context) + var isMethodAvailable by remember { mutableStateOf(true) } + var autoUpdateEnabled by remember { mutableStateOf(Settings.autoUpdateEnabled) } + var showInstallMethodMenu by remember { mutableStateOf(false) } + var isVerifyingMethod by remember { mutableStateOf(false) } + var silentInstallError by remember { mutableStateOf(null) } + + var showDownloadDialog by remember { mutableStateOf(false) } + var downloadJob by remember { mutableStateOf(null) } + var downloadError by remember { mutableStateOf(null) } + var showUpdateAvailableDialog by remember { mutableStateOf(false) } + + var notificationEnabled by remember { mutableStateOf(true) } + var dynamicNotification by remember { mutableStateOf(Settings.dynamicNotification) } + var showDisableNotificationDialog by remember { mutableStateOf(false) } + + var showLanguageDialog by remember { mutableStateOf(false) } + val availableLocales = remember { getSupportedLocales(context) } + var currentLocaleTag by remember { + val appLocales = AppCompatDelegate.getApplicationLocales() + mutableStateOf(if (appLocales.isEmpty) "" else appLocales.toLanguageTags()) + } + + LaunchedEffect(Unit) { + HookStatusClient.refresh() + } + + // Re-check states when returning from background (e.g., after granting permission) + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + HookStatusClient.refresh() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Application.notification.createNotificationChannel( + NotificationChannel( + "service", + "Service Notifications", + NotificationManager.IMPORTANCE_LOW, + ), + ) + val channel = Application.notification.getNotificationChannel("service") + notificationEnabled = channel?.importance != NotificationManager.IMPORTANCE_NONE + } else { + notificationEnabled = Application.notification.areNotificationsEnabled() + } + if (silentInstallEnabled) { + scope.launch { + val success = withContext(Dispatchers.IO) { + Vendor.verifySilentInstallMethod(silentInstallMethod) + } + isMethodAvailable = success + silentInstallError = if (success) { + null + } else { + when (silentInstallMethod) { + "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) + "SHIZUKU" -> context.getString(R.string.shizuku_not_available) + else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod) + } + } + } + } + } + + if (showTrackDialog) { + UpdateTrackDialog( + currentTrack = currentTrack, + onTrackSelected = { track -> + currentTrack = track + UpdateState.clear() + scope.launch(Dispatchers.IO) { + Settings.updateTrack = track + } + showTrackDialog = false + }, + onDismiss = { showTrackDialog = false }, + ) + } + + showErrorDialog?.let { messageRes -> + AlertDialog( + onDismissRequest = { showErrorDialog = null }, + title = { Text(stringResource(R.string.check_update)) }, + text = { Text(stringResource(messageRes)) }, + confirmButton = { + TextButton(onClick = { showErrorDialog = null }) { + Text(stringResource(R.string.ok)) + } + }, + ) + } + + if (showDownloadDialog) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.update)) }, + text = { + Column { + if (downloadError != null) { + Text( + downloadError!!, + color = MaterialTheme.colorScheme.error, + ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.downloading)) + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + downloadJob?.cancel() + downloadJob = null + showDownloadDialog = false + downloadError = null + }, + ) { + Text(stringResource(if (downloadError != null) R.string.ok else android.R.string.cancel)) + } + }, + ) + } + + if (showInstallMethodMenu) { + InstallMethodDialog( + currentMethod = silentInstallMethod, + onMethodSelected = { method -> + showInstallMethodMenu = false + if (silentInstallMethod == method) return@InstallMethodDialog + silentInstallMethod = method + Settings.silentInstallMethod = method + isVerifyingMethod = true + scope.launch { + val success = withContext(Dispatchers.IO) { + Vendor.verifySilentInstallMethod(method) + } + isVerifyingMethod = false + isMethodAvailable = success + silentInstallError = if (success) { + null + } else { + when (method) { + "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) + "SHIZUKU" -> context.getString(R.string.shizuku_not_available) + else -> context.getString(R.string.silent_install_verify_failed, method) + } + } + } + }, + onDismiss = { showInstallMethodMenu = false }, + ) + } + + if (showDisableNotificationDialog) { + AlertDialog( + onDismissRequest = { showDisableNotificationDialog = false }, + title = { Text(stringResource(R.string.enable_notification)) }, + text = { + Text( + stringResource( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + R.string.disable_notification_description + } else { + R.string.disable_notification_description_legacy + }, + ), + ) + }, + confirmButton = { + TextButton(onClick = { + showDisableNotificationDialog = false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startActivity( + Intent(AndroidSettings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply { + putExtra(AndroidSettings.EXTRA_APP_PACKAGE, context.packageName) + putExtra(AndroidSettings.EXTRA_CHANNEL_ID, "service") + }, + ) + } else { + context.startActivity( + Intent( + AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${context.packageName}"), + ), + ) + } + }) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = { showDisableNotificationDialog = false }) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) + } + + if (showUpdateAvailableDialog && updateInfo != null) { + UpdateAvailableDialog( + updateInfo = updateInfo!!, + onDismiss = { showUpdateAvailableDialog = false }, + onUpdate = { + showDownloadDialog = true + downloadError = null + downloadJob = scope.launch { + try { + withContext(Dispatchers.IO) { + Vendor.downloadAndInstall(context, updateInfo!!.downloadUrl) + } + showDownloadDialog = false + } catch (e: Exception) { + Log.e("AppSettingsScreen", "Error downloading update", e) + downloadError = e.message + } + } + }, + ) + } + + if (showLanguageDialog) { + LanguageDialog( + currentTag = currentLocaleTag, + availableLocales = availableLocales, + onLocaleSelected = { tag -> + currentLocaleTag = tag + val localeList = if (tag.isEmpty()) { + LocaleListCompat.getEmptyLocaleList() + } else { + LocaleListCompat.forLanguageTags(tag) + } + AppCompatDelegate.setApplicationLocales(localeList) + showLanguageDialog = false + }, + onDismiss = { showLanguageDialog = false }, + ) + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Info Card + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.app_version_title), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + BuildConfig.VERSION_NAME, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (hasUpdate) { + Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("New") } + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.language), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val displayName = if (currentLocaleTag.isEmpty()) { + stringResource(R.string.system_default) + } else { + val locale = Locale.forLanguageTag(currentLocaleTag) + locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) } + } + Text(displayName, style = MaterialTheme.typography.bodyMedium) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Language, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { showLanguageDialog = true }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.notification_settings), + 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 { + ListItem( + headlineContent = { + Text( + stringResource(R.string.enable_notification), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Notifications, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = notificationEnabled, + onCheckedChange = null, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { showDisableNotificationDialog = true }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + ListItem( + headlineContent = { + Text( + stringResource(R.string.dynamic_notification), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Speed, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = dynamicNotification, + onCheckedChange = { checked -> + dynamicNotification = checked + scope.launch(Dispatchers.IO) { + Settings.dynamicNotification = checked + } + }, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.update_settings), + 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 { + val updateItemCount = + run { + var count = 0 + if (Vendor.supportsTrackSelection()) { + count += 1 + } + count += 1 + if (Vendor.supportsSilentInstall()) { + count += 1 + if (silentInstallEnabled) { + count += 1 + if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) { + count += 1 + } + if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) { + count += 1 + } + } + } + if (Vendor.supportsAutoUpdate()) { + count += 1 + } + count + } + + var updateItemIndex = 0 + fun updateItemModifier(): Modifier { + val index = updateItemIndex++ + return when { + updateItemCount == 1 -> Modifier.clip(RoundedCornerShape(12.dp)) + index == 0 -> Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + index == updateItemCount - 1 -> + Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + else -> Modifier + } + } + + if (Vendor.supportsTrackSelection()) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.update_track), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + val trackName = when (UpdateTrack.fromString(currentTrack)) { + UpdateTrack.STABLE -> stringResource(R.string.update_track_stable) + UpdateTrack.BETA -> stringResource(R.string.update_track_beta) + } + Text(trackName, style = MaterialTheme.typography.bodyMedium) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.NewReleases, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + updateItemModifier() + .clickable { showTrackDialog = true }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + ListItem( + headlineContent = { + Text( + stringResource(R.string.check_update_automatic), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Autorenew, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = checkUpdateEnabled, + onCheckedChange = { checked -> + checkUpdateEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.checkUpdateEnabled = checked + } + }, + ) + }, + modifier = updateItemModifier(), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (Vendor.supportsSilentInstall()) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.silent_install), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + silentInstallError ?: stringResource(R.string.silent_install_description), + style = MaterialTheme.typography.bodySmall, + color = if (silentInstallError != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.AdminPanelSettings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (isVerifyingMethod) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Switch( + checked = silentInstallEnabled, + onCheckedChange = { checked -> + silentInstallEnabled = checked + Settings.silentInstallEnabled = checked + if (checked) { + isVerifyingMethod = true + scope.launch { + val success = withContext(Dispatchers.IO) { + Vendor.verifySilentInstallMethod(silentInstallMethod) + } + isVerifyingMethod = false + isMethodAvailable = success + silentInstallError = if (success) { + null + } else { + when (silentInstallMethod) { + "PACKAGE_INSTALLER" -> context.getString(R.string.package_installer_not_available) + "SHIZUKU" -> context.getString(R.string.shizuku_not_available) + else -> context.getString(R.string.silent_install_verify_failed, silentInstallMethod) + } + } + } + } else { + silentInstallError = null + } + }, + ) + } + }, + modifier = updateItemModifier(), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (silentInstallEnabled) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.silent_install_method), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + if (xposedActivated) { + stringResource(R.string.install_method_root) + } else { + when (silentInstallMethod) { + "PACKAGE_INSTALLER" -> stringResource(R.string.install_method_package_installer) + "SHIZUKU" -> stringResource(R.string.install_method_shizuku) + "ROOT" -> stringResource(R.string.install_method_root) + else -> silentInstallMethod + } + }, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + updateItemModifier() + .let { if (!xposedActivated) it.clickable { showInstallMethodMenu = true } else it }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (silentInstallMethod == "SHIZUKU" && !isMethodAvailable) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.get_shizuku), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.shizuku_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + updateItemModifier() + .clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/")) + context.startActivity(intent) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + if (silentInstallMethod == "PACKAGE_INSTALLER" && !isMethodAvailable) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.grant_install_permission), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.grant_install_permission_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + updateItemModifier() + .clickable { + val intent = Intent( + AndroidSettings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${context.packageName}"), + ) + context.startActivity(intent) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + if (Vendor.supportsAutoUpdate()) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.auto_update), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.auto_update_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.SystemUpdateAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = autoUpdateEnabled, + onCheckedChange = { checked -> + autoUpdateEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.autoUpdateEnabled = checked + Vendor.scheduleAutoUpdate() + } + }, + ) + }, + modifier = updateItemModifier(), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Action Section + Text( + text = stringResource(R.string.action), + 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 { + ListItem( + headlineContent = { + Text( + stringResource(R.string.check_update), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (isChecking) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + }, + modifier = + Modifier + .clip( + if (hasUpdate) { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } else { + RoundedCornerShape(12.dp) + }, + ) + .clickable(enabled = !isChecking) { + scope.launch { + UpdateState.isChecking.value = true + withContext(Dispatchers.IO) { + try { + val result = Vendor.checkUpdateAsync() + UpdateState.setUpdate(result) + if (result == null) { + showErrorDialog = R.string.no_updates_available + } else { + showUpdateAvailableDialog = true + } + } catch (_: UpdateCheckException.TrackNotSupported) { + UpdateState.setUpdate(null) + showErrorDialog = R.string.update_track_not_supported + } catch (_: Exception) { + UpdateState.setUpdate(null) + } + } + UpdateState.isChecking.value = false + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (hasUpdate && updateInfo != null) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.update), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + updateInfo!!.versionName, + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Download, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + showUpdateAvailableDialog = true + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + } +} + +@Composable +private fun UpdateTrackDialog( + currentTrack: String, + onTrackSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val tracks = listOf( + "stable" to stringResource(R.string.update_track_stable), + "beta" to stringResource(R.string.update_track_beta), + ) + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.update_track)) }, + text = { + Column { + tracks.forEach { (value, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onTrackSelected(value) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentTrack == value, + onClick = { onTrackSelected(value) }, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + +@Composable +private fun LanguageDialog( + currentTag: String, + availableLocales: List, + onLocaleSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.language)) }, + text = { + Column { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onLocaleSelected("") } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentTag.isEmpty(), + onClick = { onLocaleSelected("") }, + ) + Text( + text = stringResource(R.string.system_default), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + availableLocales.forEach { locale -> + val tag = locale.toLanguageTag() + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onLocaleSelected(tag) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentTag == tag, + onClick = { onLocaleSelected(tag) }, + ) + Text( + text = locale.getDisplayName(locale).replaceFirstChar { it.uppercase(locale) }, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} + +private fun getSupportedLocales(context: Context): List { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val localeConfig = LocaleConfig(context) + val localeList = localeConfig.supportedLocales ?: return emptyList() + return (0 until localeList.size()).map { localeList.get(it) } + } + return parseLocalesConfig(context) +} + +private fun parseLocalesConfig(context: Context): List { + val locales = mutableListOf() + try { + val resId = context.resources.getIdentifier( + "_generated_res_locale_config", + "xml", + context.packageName, + ) + if (resId == 0) return emptyList() + val parser = context.resources.getXml(resId) + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") { + val name = parser.getAttributeValue( + "http://schemas.android.com/apk/res/android", + "name", + ) + if (name != null) { + locales.add(Locale.forLanguageTag(name)) + } + } + } + } catch (_: Exception) { + } + return locales +} + +@Composable +private fun InstallMethodDialog( + currentMethod: String, + onMethodSelected: (String) -> Unit, + onDismiss: () -> Unit, +) { + val methods = buildList { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + add("PACKAGE_INSTALLER" to stringResource(R.string.install_method_package_installer)) + } + add("SHIZUKU" to stringResource(R.string.install_method_shizuku)) + add("ROOT" to stringResource(R.string.install_method_root)) + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.silent_install_method)) }, + text = { + Column { + methods.forEach { (value, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { onMethodSelected(value) } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = currentMethod == value, + onClick = { onMethodSelected(value) }, + ) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + }, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt new file mode 100644 index 0000000000..c23400c32a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/CoreSettingsScreen.kt @@ -0,0 +1,349 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.provider.DocumentsContract +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.DeleteForever +import androidx.compose.material.icons.outlined.FolderOpen +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Storage +import androidx.compose.material.icons.outlined.WarningAmber +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.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.rememberCoroutineScope +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +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.database.Settings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CoreSettingsScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.core)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + var dataSize by remember { mutableStateOf("") } + val version = remember { Libbox.version() } + var disableDeprecatedWarnings by remember { mutableStateOf(Settings.disableDeprecatedWarnings) } + + // Calculate data size on launch + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + val filesDir = context.getExternalFilesDir(null) ?: context.filesDir + val size = + filesDir.walkTopDown() + .filter { it.isFile } + .map { it.length() } + .sum() + val formattedSize = Libbox.formatBytes(size) + dataSize = formattedSize + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Core Information Card + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + // Version Info + ListItem( + headlineContent = { + Text( + stringResource(R.string.core_version_title), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + version, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = Modifier.clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + // Data Size + ListItem( + headlineContent = { + Text( + stringResource(R.string.core_data_size), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + dataSize.ifEmpty { stringResource(R.string.calculating) }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Storage, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier.clip( + RoundedCornerShape( + bottomStart = 12.dp, + bottomEnd = 12.dp, + ), + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + + // Options Section + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.options), + 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.disable_deprecated_warnings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.WarningAmber, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = disableDeprecatedWarnings, + onCheckedChange = { checked -> + disableDeprecatedWarnings = checked + scope.launch(Dispatchers.IO) { + Settings.disableDeprecatedWarnings = checked + } + }, + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + // Working Directory Section + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.working_directory), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp), + ) + + // Working Directory Card + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + // Browse + ListItem( + headlineContent = { + Text( + stringResource(R.string.browse), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.FolderOpen, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { + openInFileManager(context) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + // Destroy + ListItem( + headlineContent = { + Text( + stringResource(R.string.destroy), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.error, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.DeleteForever, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + scope.launch(Dispatchers.IO) { + val filesDir = context.getExternalFilesDir(null) ?: context.filesDir + filesDir.deleteRecursively() + filesDir.mkdirs() + + // Recalculate data size + val newSize = + filesDir.walkTopDown() + .filter { it.isFile } + .map { it.length() } + .sum() + val formattedSize = Libbox.formatBytes(newSize) + dataSize = formattedSize + } + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +private fun openInFileManager(context: Context) { + val authority = "${context.packageName}.workingdir" + val rootUri = DocumentsContract.buildRootUri(authority, "working_directory") + + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(rootUri, DocumentsContract.Document.MIME_TYPE_DIR) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + } + + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText( + context, + context.getString(R.string.no_file_manager), + Toast.LENGTH_SHORT, + ).show() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt new file mode 100644 index 0000000000..dbcf6bc42c --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/PrivilegeSettingsScreen.kt @@ -0,0 +1,968 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.content.Intent +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.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +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.outlined.KeyboardArrowRight +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.outlined.AppShortcut +import androidx.compose.material.icons.outlined.BugReport +import androidx.compose.material.icons.outlined.CheckBox +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.FilterAlt +import androidx.compose.material.icons.outlined.ViewModule +import androidx.compose.material.icons.outlined.WarningAmber +import androidx.compose.material3.AlertDialog +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.OutlinedTextField +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.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.alpha +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.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.topbar.OverrideTopBar +import io.nekohasekai.sfa.constant.Status +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.utils.DetectionResult +import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier +import io.nekohasekai.sfa.utils.HookStatusClient +import io.nekohasekai.sfa.utils.PrivilegeSettingsClient +import io.nekohasekai.sfa.utils.VpnDetectionTest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PrivilegeSettingsScreen(navController: NavController, serviceStatus: Status = Status.Stopped) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.privilege_settings)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + val systemHookStatus by HookStatusClient.status.collectAsState() + var privilegeSettingsEnabled by remember { mutableStateOf(Settings.privilegeSettingsEnabled) } + + var showTestDialog by remember { mutableStateOf(false) } + var testResult by remember { mutableStateOf(null) } + var isTestRunning by remember { mutableStateOf(false) } + var interfaceRenameEnabled by remember { mutableStateOf(Settings.privilegeSettingsInterfaceRenameEnabled) } + var interfacePrefix by remember { mutableStateOf(Settings.privilegeSettingsInterfacePrefix) } + var showInterfacePrefixDialog by remember { mutableStateOf(false) } + var interfacePrefixInput by remember { mutableStateOf(interfacePrefix) } + var showExportProgressDialog by remember { mutableStateOf(false) } + var exportCancelled by remember { mutableStateOf(false) } + var exportError by remember { mutableStateOf(null) } + var showExportSuccessDialog by remember { mutableStateOf(false) } + var exportedFile by remember { mutableStateOf(null) } + var showMessageDialog by remember { mutableStateOf(false) } + var messageDialogTitle by remember { mutableStateOf("") } + var messageDialogMessage by remember { mutableStateOf("") } + + val saveFileLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/zip"), + ) { uri -> + val file = exportedFile + if (uri != null && file != null) { + scope.launch(Dispatchers.IO) { + try { + context.contentResolver.openOutputStream(uri)?.use { output -> + FileInputStream(file).use { input -> + input.copyTo(output) + } + } + } catch (e: Throwable) { + android.util.Log.e("PrivilegeSettings", "Failed to save file", e) + } + } + } + showExportSuccessDialog = false + exportedFile = null + } + + androidx.compose.runtime.LaunchedEffect(Unit) { + HookStatusClient.refresh() + } + + val hasPendingDowngrade = HookModuleUpdateNotifier.isDowngrade(systemHookStatus) + val hasPendingUpdate = HookModuleUpdateNotifier.isUpgrade(systemHookStatus) + val hasPendingChange = hasPendingDowngrade || hasPendingUpdate + androidx.compose.runtime.LaunchedEffect(systemHookStatus) { + HookModuleUpdateNotifier.maybeNotify(context, systemHookStatus) + } + + if (showTestDialog) { + SelfTestDialog( + isRunning = isTestRunning, + result = testResult, + onDismiss = { + showTestDialog = false + testResult = null + }, + ) + } + if (showInterfacePrefixDialog) { + AlertDialog( + onDismissRequest = { showInterfacePrefixDialog = false }, + title = { Text(stringResource(R.string.privilege_settings_interface_rename_title)) }, + text = { + OutlinedTextField( + value = interfacePrefixInput, + onValueChange = { interfacePrefixInput = it }, + singleLine = true, + label = { Text(stringResource(R.string.privilege_settings_interface_prefix)) }, + ) + }, + confirmButton = { + TextButton( + onClick = { + val trimmed = interfacePrefixInput.trim() + val filtered = buildString(trimmed.length) { + for (ch in trimmed) { + if (ch.isLetterOrDigit() || ch == '_') { + append(ch) + } + } + } + val normalized = if (filtered.isEmpty()) "en" else filtered + interfacePrefix = normalized + Settings.privilegeSettingsInterfacePrefix = normalized + showInterfacePrefixDialog = false + scope.launch { + val failure = + withContext(Dispatchers.IO) { + PrivilegeSettingsClient.sync() + } + if (failure != null) { + messageDialogTitle = context.getString(R.string.error_title) + messageDialogMessage = failure.message ?: failure.toString() + showMessageDialog = true + } else if (serviceStatus == Status.Started) { + GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } + } + }, + ) { + Text(stringResource(R.string.save)) + } + }, + dismissButton = { + TextButton(onClick = { showInterfacePrefixDialog = false }) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + if (showMessageDialog) { + SelectableMessageDialog( + title = messageDialogTitle, + message = messageDialogMessage, + onDismiss = { showMessageDialog = false }, + ) + } + if (showExportProgressDialog) { + AlertDialog( + onDismissRequest = {}, + title = { Text(stringResource(R.string.privilege_settings_export_debug)) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text( + if (exportError != null) { + exportError!! + } else { + stringResource(R.string.exporting) + }, + ) + } + }, + confirmButton = { + TextButton( + onClick = { + if (exportError != null) { + showExportProgressDialog = false + exportError = null + } else { + exportCancelled = true + showExportProgressDialog = false + } + }, + ) { + Text(stringResource(if (exportError != null) R.string.ok else android.R.string.cancel)) + } + }, + ) + } + if (showExportSuccessDialog && exportedFile != null) { + AlertDialog( + onDismissRequest = { + showExportSuccessDialog = false + exportedFile = null + }, + title = { Text(stringResource(R.string.privilege_settings_export_debug_complete)) }, + text = { + val file = exportedFile + if (file != null) { + Text(stringResource(R.string.privilege_settings_export_debug_message, Libbox.formatBytes(file.length()))) + } + }, + confirmButton = { + TextButton( + onClick = { + val file = exportedFile ?: return@TextButton + val uri = FileProvider.getUriForFile( + context, + "${context.packageName}.cache", + file, + ) + 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)) + showExportSuccessDialog = false + exportedFile = null + }, + ) { + Text(stringResource(R.string.menu_share)) + } + }, + dismissButton = { + TextButton( + onClick = { + val file = exportedFile ?: return@TextButton + saveFileLauncher.launch(file.name) + }, + ) { + Text(stringResource(R.string.save)) + } + }, + ) + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + val isLsposedActivated = systemHookStatus?.active == true + val showLogs = isLsposedActivated && !hasPendingChange + val showExportDebug = showLogs + val statusShape = + if (showLogs || hasPendingChange) { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } else { + RoundedCornerShape(12.dp) + } + val logItemShape = + if (showExportDebug) { + RoundedCornerShape(0.dp) + } else { + RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + } + val statusLabel = + when { + hasPendingDowngrade -> stringResource(R.string.lsposed_module_pending_downgrade) + hasPendingUpdate -> stringResource(R.string.lsposed_module_pending_update) + isLsposedActivated -> stringResource(R.string.lsposed_module_activated) + else -> stringResource(R.string.lsposed_module_not_activated) + } + val statusIcon = + when { + hasPendingDowngrade -> Icons.Outlined.WarningAmber + hasPendingUpdate -> Icons.Outlined.WarningAmber + isLsposedActivated -> Icons.Outlined.CheckBox + else -> Icons.Outlined.WarningAmber + } + val statusIconTint = + when { + hasPendingDowngrade -> MaterialTheme.colorScheme.error + hasPendingUpdate -> Color(0xFFFFC107) + isLsposedActivated -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.error + } + + Text( + text = stringResource(R.string.privilege_module_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, top = 16.dp, bottom = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + ListItem( + headlineContent = { + Text( + statusLabel, + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = null, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = statusIcon, + contentDescription = null, + tint = statusIconTint, + ) + }, + modifier = Modifier.clip(statusShape), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + if (showLogs) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.privilege_settings_view_logs), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.ViewModule, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = + Modifier + .clip(logItemShape) + .clickable { + navController.navigate("settings/privilege/logs") + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + if (showExportDebug) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.privilege_settings_export_debug), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.BugReport, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + val exportBase = File(context.cacheDir, "debug") + if (!exportBase.exists()) { + exportBase.mkdirs() + } + val timestamp = + SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) + val outZip = File(exportBase, "sing-box-lsposed-debug-$timestamp.zip") + exportCancelled = false + exportError = null + showExportProgressDialog = true + scope.launch { + val result = withContext(Dispatchers.IO) { + PrivilegeSettingsClient.exportDebugInfo(outZip.absolutePath) + } + if (exportCancelled) { + outZip.delete() + return@launch + } + showExportProgressDialog = false + val failure = result.error + if (failure == null) { + exportedFile = outZip + showExportSuccessDialog = true + } else { + messageDialogTitle = context.getString(R.string.error_title) + messageDialogMessage = context.getString( + R.string.privilege_settings_export_debug_failed, + failure, + ) + showMessageDialog = true + } + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + if (hasPendingChange) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.privilege_module_restart_action), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + scope.launch { + val failure = withContext(Dispatchers.IO) { + runCatching { + val process = Runtime.getRuntime().exec( + arrayOf( + "su", + "-c", + "/system/bin/svc power reboot || /system/bin/reboot", + ), + ) + val error = process.errorStream.bufferedReader().use { it.readText().trim() } + process.inputStream.close() + process.outputStream.close() + process.errorStream.close() + val code = process.waitFor() + if (code == 0) { + null + } else { + error.ifBlank { "exit=$code" } + } + }.getOrElse { it.message ?: "unknown" } + } + if (failure != null) { + val message = + if (failure == "unknown" || failure.startsWith("exit=")) { + context.getString(R.string.root_access_required) + } else { + context.getString(R.string.privilege_module_restart_failed, failure) + } + messageDialogTitle = context.getString(R.string.error_title) + messageDialogMessage = message + showMessageDialog = true + } + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Text( + text = stringResource(R.string.privilege_settings_hide_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp), + ) + + val privilegeControlsEnabled = isLsposedActivated && !hasPendingChange + val hasManageItem = privilegeSettingsEnabled + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + val disabledAlpha = 0.38f + ListItem( + headlineContent = { + Text( + stringResource(R.string.enabled), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.privilege_settings_hide_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.FilterAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = privilegeSettingsEnabled, + onCheckedChange = { checked -> + privilegeSettingsEnabled = checked + if (checked && !interfaceRenameEnabled) { + interfaceRenameEnabled = true + } + scope.launch { + val failure = + withContext(Dispatchers.IO) { + Settings.privilegeSettingsEnabled = checked + if (checked) { + Settings.privilegeSettingsInterfaceRenameEnabled = true + } + PrivilegeSettingsClient.sync() + } + if (failure != null) { + messageDialogTitle = context.getString(R.string.error_title) + messageDialogMessage = failure.message ?: failure.toString() + showMessageDialog = true + } else if (checked && serviceStatus == Status.Started) { + GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } + } + }, + enabled = privilegeControlsEnabled, + ) + }, + modifier = Modifier + .alpha(if (privilegeControlsEnabled) 1f else disabledAlpha) + .clip( + if (hasManageItem) { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } else { + RoundedCornerShape(12.dp) + }, + ), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + val manageEnabled = privilegeControlsEnabled && privilegeSettingsEnabled + if (hasManageItem) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.privilege_settings_hide_manage), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.AppShortcut, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .alpha(if (manageEnabled) 1f else disabledAlpha) + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable(enabled = manageEnabled) { + navController.navigate("settings/privilege/manage") + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Text( + text = stringResource(R.string.privilege_settings_interface_rename_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + val renameControlsEnabled = isLsposedActivated && !hasPendingChange + val disabledAlpha = 0.38f + ListItem( + headlineContent = { + Text( + stringResource(R.string.enabled), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.FilterAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = interfaceRenameEnabled, + onCheckedChange = { checked -> + interfaceRenameEnabled = checked + scope.launch { + val failure = + withContext(Dispatchers.IO) { + Settings.privilegeSettingsInterfaceRenameEnabled = checked + PrivilegeSettingsClient.sync() + } + if (failure != null) { + messageDialogTitle = context.getString(R.string.error_title) + messageDialogMessage = failure.message ?: failure.toString() + showMessageDialog = true + } else if (serviceStatus == Status.Started) { + GlobalEventBus.tryEmit(UiEvent.RestartToTakeEffect) + } + } + }, + enabled = renameControlsEnabled, + ) + }, + modifier = Modifier + .alpha(if (renameControlsEnabled) 1f else disabledAlpha) + .clip( + if (interfaceRenameEnabled) { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } else { + RoundedCornerShape(12.dp) + }, + ), + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (interfaceRenameEnabled) { + val prefixEnabled = renameControlsEnabled + ListItem( + headlineContent = { + Text( + stringResource(R.string.privilege_settings_interface_prefix), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + interfacePrefix, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .alpha(if (prefixEnabled) 1f else disabledAlpha) + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable(enabled = prefixEnabled) { + interfacePrefixInput = interfacePrefix + showInterfacePrefixDialog = true + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + Text( + text = stringResource(R.string.privilege_settings_vpn_detection_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, top = 24.dp, bottom = 8.dp), + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + val testEnabled = !hasPendingChange + ListItem( + headlineContent = { + Text( + stringResource(R.string.privilege_settings_hide_test), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.BugReport, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier + .alpha(if (testEnabled) 1f else 0.38f) + .clip(RoundedCornerShape(12.dp)) + .clickable(enabled = testEnabled) { + showTestDialog = true + isTestRunning = true + testResult = null + scope.launch { + val result = withContext(Dispatchers.IO) { + VpnDetectionTest.runDetection(context) + } + testResult = result + isTestRunning = false + } + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } +} + +@Composable +private fun SelfTestDialog(isRunning: Boolean, result: DetectionResult?, onDismiss: () -> Unit) { + val notDetectedText = stringResource(R.string.privilege_settings_hide_test_not_detected) + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text(stringResource(R.string.privilege_settings_hide_test_result)) + }, + text = { + if (isRunning) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator() + Text( + text = stringResource(R.string.privilege_settings_hide_test_running), + modifier = Modifier.padding(start = 16.dp), + ) + } + } else if (result != null) { + val frameworkInterfacesText = result.frameworkInterfaces + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") + val frameworkProxyText = result.httpProxy?.takeIf { it.isNotBlank() } + val frameworkExtraLines = listOfNotNull(frameworkInterfacesText, frameworkProxyText) + val nativeInterfacesText = result.nativeInterfaces + .takeIf { it.isNotEmpty() } + ?.joinToString(", ") + val nativeExtraLines = listOfNotNull(nativeInterfacesText) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column { + Text( + text = "Framework", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (result.frameworkDetected.isEmpty()) { + Text( + text = notDetectedText, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF4CAF50), + ) + } else { + Text( + text = result.frameworkDetected.joinToString(", "), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + if (frameworkExtraLines.isNotEmpty()) { + Column( + modifier = Modifier.padding(top = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + frameworkExtraLines.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + + Column { + Text( + text = "Native", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!result.nativeDetected) { + Text( + text = notDetectedText, + style = MaterialTheme.typography.bodyMedium, + color = Color(0xFF4CAF50), + ) + } else { + Text( + text = "getifaddrs()", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp), + ) + if (nativeExtraLines.isNotEmpty()) { + Column( + modifier = Modifier.padding(top = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + nativeExtraLines.forEach { line -> + Text( + text = line, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.close)) + } + }, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt new file mode 100644 index 0000000000..22370e8a51 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ProfileOverrideScreen.kt @@ -0,0 +1,687 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.content.Intent +import android.net.Uri +import android.widget.Toast +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.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.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.AppShortcut +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material.icons.outlined.Route +import androidx.compose.material.icons.outlined.SmartToy +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.AlertDialog +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.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.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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.navigation.NavController +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.RootClient +import io.nekohasekai.sfa.compose.screen.profileoverride.PerAppProxyScanner +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.vendor.PackageQueryManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProfileOverrideScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.profile_override)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var autoRedirect by remember { mutableStateOf(Settings.autoRedirect) } + var perAppProxyEnabled by remember { mutableStateOf(Settings.perAppProxyEnabled) } + var managedModeEnabled by remember { mutableStateOf(Settings.perAppProxyManagedMode) } + var isScanning by remember { mutableStateOf(false) } + + fun scanAndSaveManagedList() { + isScanning = true + scope.launch { + val chinaApps = PerAppProxyScanner.scanAllChinaApps() + withContext(Dispatchers.IO) { + Settings.perAppProxyManagedList = chinaApps + } + isScanning = false + } + } + + var showShizukuDialog by remember { mutableStateOf(false) } + var showRootDialog by remember { mutableStateOf(false) } + var showModeDialog by remember { mutableStateOf(false) } + + val showModeSelector = PackageQueryManager.showModeSelector + var packageQueryMode by remember { mutableStateOf(Settings.perAppProxyPackageQueryMode) } + val useRootMode = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT + + val isShizukuInstalled by PackageQueryManager.shizukuInstalled.collectAsState() + val isShizukuBinderReady by PackageQueryManager.shizukuBinderReady.collectAsState() + val isShizukuPermissionGranted by PackageQueryManager.shizukuPermissionGranted.collectAsState() + val isShizukuAvailable = isShizukuBinderReady && isShizukuPermissionGranted + var isShizukuStateInitialized by remember(showModeSelector) { mutableStateOf(!showModeSelector) } + + DisposableEffect(showModeSelector) { + if (showModeSelector) { + isShizukuStateInitialized = false + PackageQueryManager.registerListeners() + PackageQueryManager.refreshShizukuState() + isShizukuStateInitialized = true + } + onDispose { + if (showModeSelector) { + PackageQueryManager.unregisterListeners() + isShizukuStateInitialized = false + } + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner, showModeSelector) { + if (!showModeSelector) return@DisposableEffect onDispose { } + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + PackageQueryManager.refreshShizukuState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + // Auto-disable per-app proxy if Shizuku authorization is revoked (only when using Shizuku mode) + LaunchedEffect(isShizukuAvailable, useRootMode, isShizukuStateInitialized, perAppProxyEnabled, showModeSelector) { + if ( + showModeSelector && + !useRootMode && + isShizukuStateInitialized && + perAppProxyEnabled && + !PackageQueryManager.isShizukuAvailable() + ) { + perAppProxyEnabled = false + withContext(Dispatchers.IO) { + Settings.perAppProxyEnabled = false + } + } + } + + // Auto-close dialog and enable feature when Shizuku becomes available + LaunchedEffect(isShizukuAvailable) { + if (showModeSelector && isShizukuAvailable && showShizukuDialog) { + showShizukuDialog = false + perAppProxyEnabled = true + withContext(Dispatchers.IO) { + Settings.perAppProxyEnabled = true + } + if (managedModeEnabled) { + scanAndSaveManagedList() + } + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Card 1: Auto Redirect + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + ListItem( + headlineContent = { + Text( + stringResource(R.string.auto_redirect), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.auto_redirect_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Route, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = autoRedirect, + onCheckedChange = { checked -> + if (checked && !autoRedirect) { + scope.launch { + val hasRoot = RootClient.checkRootAvailable() + if (hasRoot) { + autoRedirect = true + withContext(Dispatchers.IO) { + Settings.autoRedirect = true + } + } else { + Toast.makeText( + context, + context.getString(R.string.root_access_required), + Toast.LENGTH_LONG, + ).show() + } + } + } else if (!checked) { + autoRedirect = false + scope.launch(Dispatchers.IO) { + Settings.autoRedirect = false + } + } + }, + ) + }, + modifier = Modifier.clip(RoundedCornerShape(12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + // Section: Per-App Proxy + val canUsePerAppProxy = if (showModeSelector) { + if (useRootMode) true else isShizukuAvailable + } else { + true + } + + Text( + text = stringResource(R.string.per_app_proxy), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 32.dp, top = 16.dp, bottom = 8.dp), + ) + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + // Mode selector (only when privileged query is needed) + if (showModeSelector) { + val modeEnabled = !perAppProxyEnabled + val disabledAlpha = 0.38f + ListItem( + headlineContent = { + Text( + stringResource(R.string.per_app_proxy_package_query_mode), + style = MaterialTheme.typography.bodyLarge, + color = if (modeEnabled) { + Color.Unspecified + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) + }, + ) + }, + supportingContent = { + Text( + if (useRootMode) "ROOT" else "Shizuku", + style = MaterialTheme.typography.bodyMedium, + color = if (modeEnabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = disabledAlpha) + }, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = if (modeEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) + }, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = if (modeEnabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) + }, + ) + }, + modifier = Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable(enabled = modeEnabled) { showModeDialog = true }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + + // Enabled toggle + ListItem( + headlineContent = { + Text( + stringResource(R.string.enabled), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.FilterList, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Switch( + checked = perAppProxyEnabled, + onCheckedChange = { checked -> + if (checked && showModeSelector) { + if (useRootMode) { + showRootDialog = true + } else { + showShizukuDialog = true + } + } else { + perAppProxyEnabled = checked + scope.launch(Dispatchers.IO) { + Settings.perAppProxyEnabled = checked + } + if (checked && managedModeEnabled) { + scanAndSaveManagedList() + } + } + }, + enabled = !isScanning, + ) + }, + modifier = + Modifier.clip( + if (showModeSelector) { + RoundedCornerShape(0.dp) + } else if (perAppProxyEnabled && canUsePerAppProxy) { + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } else { + RoundedCornerShape(12.dp) + }, + ), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + if (perAppProxyEnabled && canUsePerAppProxy) { + // Manage entry + val manageEnabled = !managedModeEnabled + val disabledAlpha = 0.38f + ListItem( + headlineContent = { + Text( + stringResource(R.string.per_app_proxy_manage), + style = MaterialTheme.typography.bodyLarge, + color = if (manageEnabled) { + Color.Unspecified + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) + }, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.AppShortcut, + contentDescription = null, + tint = if (manageEnabled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) + }, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight, + contentDescription = null, + tint = if (manageEnabled) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = disabledAlpha) + }, + ) + }, + modifier = + Modifier.clickable(enabled = manageEnabled) { + navController.navigate("settings/profile_override/manage") + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + // Managed Mode toggle + ListItem( + headlineContent = { + Text( + stringResource(R.string.per_app_proxy_managed_mode), + style = MaterialTheme.typography.bodyLarge, + ) + }, + supportingContent = { + Text( + stringResource(R.string.per_app_proxy_managed_mode_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.SmartToy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (isScanning) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } else { + Switch( + checked = managedModeEnabled, + onCheckedChange = { checked -> + if (checked) { + managedModeEnabled = true + scope.launch(Dispatchers.IO) { + Settings.perAppProxyManagedMode = true + } + scanAndSaveManagedList() + } else { + managedModeEnabled = false + scope.launch(Dispatchers.IO) { + Settings.perAppProxyManagedMode = false + } + } + }, + ) + } + }, + modifier = Modifier.clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)), + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + } + + // Shizuku dialog + if (showShizukuDialog) { + AlertDialog( + onDismissRequest = { showShizukuDialog = false }, + title = { + Text(stringResource(R.string.per_app_proxy)) + }, + text = { + Text(stringResource(R.string.per_app_proxy_shizuku_required)) + }, + confirmButton = { + when { + isShizukuAvailable -> { + TextButton( + onClick = { + showShizukuDialog = false + perAppProxyEnabled = true + scope.launch(Dispatchers.IO) { + Settings.perAppProxyEnabled = true + } + if (managedModeEnabled) { + scanAndSaveManagedList() + } + }, + ) { + Text(stringResource(R.string.ok)) + } + } + isShizukuBinderReady -> { + TextButton( + onClick = { + PackageQueryManager.requestShizukuPermission() + }, + ) { + Text(stringResource(R.string.request_shizuku)) + } + } + isShizukuInstalled -> { + TextButton( + onClick = { + showShizukuDialog = false + val intent = context.packageManager.getLaunchIntentForPackage("moe.shizuku.privileged.api") + if (intent != null) { + context.startActivity(intent) + } + }, + ) { + Text(stringResource(R.string.start_shizuku)) + } + } + else -> { + TextButton( + onClick = { + showShizukuDialog = false + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://shizuku.rikka.app/")) + context.startActivity(intent) + }, + ) { + Text(stringResource(R.string.get_shizuku)) + } + } + } + }, + dismissButton = { + if (!isShizukuAvailable) { + TextButton( + onClick = { showShizukuDialog = false }, + ) { + Text(stringResource(R.string.cancel)) + } + } + }, + ) + } + + // ROOT dialog + if (showRootDialog) { + AlertDialog( + onDismissRequest = { showRootDialog = false }, + title = { + Text(stringResource(R.string.per_app_proxy)) + }, + text = { + Text(stringResource(R.string.per_app_proxy_root_required)) + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + val hasRoot = PackageQueryManager.checkRootAvailable() + if (hasRoot) { + showRootDialog = false + perAppProxyEnabled = true + withContext(Dispatchers.IO) { + Settings.perAppProxyEnabled = true + } + if (managedModeEnabled) { + scanAndSaveManagedList() + } + } else { + showRootDialog = false + Toast.makeText( + context, + R.string.root_access_denied, + Toast.LENGTH_LONG, + ).show() + } + } + }, + ) { + Text(stringResource(R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { showRootDialog = false }, + ) { + Text(stringResource(R.string.cancel)) + } + }, + ) + } + + // Mode selection dialog + if (showModeDialog) { + AlertDialog( + onDismissRequest = { showModeDialog = false }, + title = { + Text(stringResource(R.string.per_app_proxy_package_query_mode)) + }, + text = { + Column { + ListItem( + headlineContent = { Text("Shizuku") }, + leadingContent = { + RadioButton( + selected = packageQueryMode == Settings.PACKAGE_QUERY_MODE_SHIZUKU, + onClick = null, + ) + }, + modifier = Modifier.clickable { + packageQueryMode = Settings.PACKAGE_QUERY_MODE_SHIZUKU + PackageQueryManager.setQueryMode(Settings.PACKAGE_QUERY_MODE_SHIZUKU) + scope.launch(Dispatchers.IO) { + Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_SHIZUKU + } + if ( + perAppProxyEnabled && + isShizukuStateInitialized && + !PackageQueryManager.isShizukuAvailable() + ) { + perAppProxyEnabled = false + scope.launch(Dispatchers.IO) { + Settings.perAppProxyEnabled = false + } + } + showModeDialog = false + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + ListItem( + headlineContent = { Text("ROOT") }, + leadingContent = { + RadioButton( + selected = packageQueryMode == Settings.PACKAGE_QUERY_MODE_ROOT, + onClick = null, + ) + }, + modifier = Modifier.clickable { + packageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT + PackageQueryManager.setQueryMode(Settings.PACKAGE_QUERY_MODE_ROOT) + scope.launch(Dispatchers.IO) { + Settings.perAppProxyPackageQueryMode = Settings.PACKAGE_QUERY_MODE_ROOT + } + showModeDialog = false + }, + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + }, + confirmButton = {}, + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt new file mode 100644 index 0000000000..7f89fff529 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/ServiceSettingsScreen.kt @@ -0,0 +1,176 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.outlined.BatteryChargingFull +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.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +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.sfa.R +import io.nekohasekai.sfa.bg.ServiceConnection +import io.nekohasekai.sfa.compose.topbar.OverrideTopBar +import io.nekohasekai.sfa.ktx.launchCustomTab + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServiceSettingsScreen(navController: NavController, serviceConnection: ServiceConnection? = null) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.service)) }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.content_description_back), + ) + } + }, + ) + } + + val context = LocalContext.current + // Check battery optimization status + var isBatteryOptimizationIgnored by remember { mutableStateOf(false) } + // Activity result launcher for battery optimization permission + val requestBatteryOptimizationLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { _ -> + // Recheck the status after returning from settings + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pm = context.getSystemService(PowerManager::class.java) + isBatteryOptimizationIgnored = + pm?.isIgnoringBatteryOptimizations(context.packageName) == true + } + } + + // Check battery optimization status on launch + LaunchedEffect(Unit) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pm = context.getSystemService(PowerManager::class.java) + isBatteryOptimizationIgnored = + pm?.isIgnoringBatteryOptimizations(context.packageName) == true + } else { + isBatteryOptimizationIgnored = true + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // Background Permission Card (only show if battery optimization is not ignored) + if (!isBatteryOptimizationIgnored && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.BatteryChargingFull, + contentDescription = null, + tint = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(end = 12.dp), + ) + Text( + stringResource(R.string.background_permission), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + + Text( + stringResource(R.string.background_permission_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { + context.launchCustomTab("https://dontkillmyapp.com/") + }, + modifier = Modifier.padding(end = 8.dp), + ) { + Text(stringResource(R.string.read_more)) + } + + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val intent = + Intent( + android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:${context.packageName}"), + ) + requestBatteryOptimizationLauncher.launch(intent) + } + }, + ) { + Text(stringResource(R.string.request_background_permission)) + } + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt new file mode 100644 index 0000000000..f468f51b21 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/screen/settings/SettingsScreen.kt @@ -0,0 +1,366 @@ +package io.nekohasekai.sfa.compose.screen.settings + +import android.os.Build +import android.os.PowerManager +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.outlined.AdminPanelSettings +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.Description +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.Tune +import androidx.compose.material3.Badge +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +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.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.topbar.OverrideTopBar +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.update.UpdateState +import io.nekohasekai.sfa.utils.HookModuleUpdateNotifier +import io.nekohasekai.sfa.utils.HookStatusClient + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(navController: NavController) { + OverrideTopBar { + TopAppBar( + title = { Text(stringResource(R.string.title_settings)) }, + ) + } + + val context = LocalContext.current + val scope = rememberCoroutineScope() + val hasUpdate by UpdateState.hasUpdate + val hookStatus by HookStatusClient.status.collectAsState() + val hasPendingPrivilegeDowngrade = HookModuleUpdateNotifier.isDowngrade(hookStatus) + val hasPendingPrivilegeUpdate = HookModuleUpdateNotifier.isUpgrade(hookStatus) + var isBatteryOptimizationIgnored by remember { mutableStateOf(true) } + + LaunchedEffect(Unit) { + HookStatusClient.refresh() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val pm = context.getSystemService(PowerManager::class.java) + isBatteryOptimizationIgnored = + pm?.isIgnoringBatteryOptimizations(context.packageName) == true + } + } + + Column( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(vertical = 8.dp), + ) { + // General Settings Group + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column { + ListItem( + headlineContent = { + Text( + stringResource(R.string.title_app_settings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (hasUpdate) { + Badge(containerColor = MaterialTheme.colorScheme.primary) + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { navController.navigate("settings/app") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.core), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clickable { navController.navigate("settings/core") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.service), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Tune, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (!isBatteryOptimizationIgnored) { + Badge(containerColor = MaterialTheme.colorScheme.primary) + } + }, + modifier = Modifier.clickable { navController.navigate("settings/service") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.profile_override), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.FilterAlt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + modifier = + Modifier + .clickable { navController.navigate("settings/profile_override") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.privilege_settings), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.AdminPanelSettings, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + if (hasPendingPrivilegeDowngrade) { + Badge(containerColor = MaterialTheme.colorScheme.error) + } else if (hasPendingPrivilegeUpdate) { + Badge(containerColor = Color(0xFFFFC107)) + } + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { navController.navigate("settings/privilege") }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + + // About Section + Text( + text = stringResource(R.string.about), + 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 { + ListItem( + headlineContent = { + Text( + stringResource(R.string.error_deprecated_documentation), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + .clickable { + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) + intent.data = android.net.Uri.parse("https://sing-box.sagernet.org/") + context.startActivity(intent) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.source_code), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Code, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = + Modifier + .clickable { + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) + intent.data = + android.net.Uri.parse("https://github.com/SagerNet/sing-box-for-android") + context.startActivity(intent) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + + ListItem( + headlineContent = { + Text( + stringResource(R.string.sponsor), + style = MaterialTheme.typography.bodyLarge, + ) + }, + leadingContent = { + Icon( + imageVector = Icons.Outlined.Favorite, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = + Modifier + .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) + .clickable { + val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) + intent.data = android.net.Uri.parse("https://sekai.icu/sponsors/") + context.startActivity(intent) + }, + colors = + ListItemDefaults.colors( + containerColor = Color.Transparent, + ), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt new file mode 100644 index 0000000000..2272ebe8f4 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/shared/AppSelectionComponents.kt @@ -0,0 +1,301 @@ +package io.nekohasekai.sfa.compose.shared + +import android.Manifest +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.BitmapDrawable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.nekohasekai.sfa.R + +enum class SortMode { + NAME, + PACKAGE_NAME, + UID, + INSTALL_TIME, + UPDATE_TIME, +} + +class PackageCache( + private val packageInfo: PackageInfo, + private val appInfo: ApplicationInfo, + private val packageManager: PackageManager, +) { + val packageName: String get() = packageInfo.packageName + + val uid: Int get() = packageInfo.applicationInfo!!.uid + + val installTime: Long get() = packageInfo.firstInstallTime + val updateTime: Long get() = packageInfo.lastUpdateTime + val isSystem: Boolean get() = appInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1 + val isOffline: Boolean + get() = packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) != true + val isDisabled: Boolean get() = appInfo.flags and ApplicationInfo.FLAG_INSTALLED == 0 + + val applicationIcon by lazy { + val drawable = appInfo.loadIcon(packageManager) + val bitmap = + if (drawable is BitmapDrawable) { + drawable.bitmap + } else { + val imageBitmap = + Bitmap.createBitmap( + drawable.intrinsicWidth.coerceAtLeast(1), + drawable.intrinsicHeight.coerceAtLeast(1), + Bitmap.Config.ARGB_8888, + ) + val canvas = Canvas(imageBitmap) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + imageBitmap + } + bitmap.asImageBitmap() + } + + val applicationLabel by lazy { + appInfo.loadLabel(packageManager).toString() + } + + val info: PackageInfo get() = packageInfo +} + +fun buildDisplayPackages( + packages: List, + selectedUids: Set = emptySet(), + selectedFirst: Boolean = false, + hideSystemApps: Boolean, + hideOfflineApps: Boolean, + hideDisabledApps: Boolean, + sortMode: SortMode, + sortReverse: Boolean, +): List { + val displayPackages = + packages.filter { packageCache -> + if (hideSystemApps && packageCache.isSystem) { + return@filter false + } + if (hideOfflineApps && packageCache.isOffline) { + return@filter false + } + if (hideDisabledApps && packageCache.isDisabled) { + return@filter false + } + true + } + val sortComparator = + Comparator { left, right -> + if (selectedFirst) { + val selectedCompare = + compareValues( + !selectedUids.contains(left.uid), + !selectedUids.contains(right.uid), + ) + if (selectedCompare != 0) { + return@Comparator selectedCompare + } + } + val value = + when (sortMode) { + SortMode.NAME -> compareValues(left.applicationLabel, right.applicationLabel) + SortMode.PACKAGE_NAME -> compareValues(left.packageName, right.packageName) + SortMode.UID -> compareValues(left.uid, right.uid) + SortMode.INSTALL_TIME -> compareValues(left.installTime, right.installTime) + SortMode.UPDATE_TIME -> compareValues(left.updateTime, right.updateTime) + } + if (sortReverse) -value else value + } + return displayPackages.sortedWith(sortComparator) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AppSelectionCard( + packageCache: PackageCache, + selected: Boolean, + onToggle: (Boolean) -> Unit, + enableCopyActions: Boolean = true, + onCopyLabel: (() -> Unit)? = null, + onCopyPackage: (() -> Unit)? = null, + onCopyUid: (() -> Unit)? = null, +) { + var showContextMenu by remember { mutableStateOf(false) } + var showCopyMenu by remember { mutableStateOf(false) } + val cardShape = MaterialTheme.shapes.medium + val cardModifier = + if (enableCopyActions) { + Modifier + .fillMaxWidth() + .clip(cardShape) + .combinedClickable( + onClick = { onToggle(!selected) }, + onLongClick = { showContextMenu = true }, + ) + } else { + Modifier + .fillMaxWidth() + .clip(cardShape) + .clickable { onToggle(!selected) } + } + + Box { + Card( + modifier = cardModifier, + shape = cardShape, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + ), + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Image( + bitmap = packageCache.applicationIcon, + contentDescription = stringResource(R.string.content_description_app_icon), + modifier = Modifier.size(40.dp), + ) + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = packageCache.applicationLabel, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${packageCache.packageName} (${packageCache.uid})", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = true, + ) + } + Switch( + checked = selected, + onCheckedChange = { onToggle(it) }, + ) + } + } + + if (enableCopyActions) { + DropdownMenu( + expanded = showContextMenu, + onDismissRequest = { + showContextMenu = false + showCopyMenu = false + }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_action_copy)) }, + onClick = { showCopyMenu = !showCopyMenu }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingIcon = { + Icon( + imageVector = + if (showCopyMenu) { + Icons.Default.ExpandLess + } else { + Icons.Default.ExpandMore + }, + contentDescription = null, + ) + }, + ) + if (showCopyMenu) { + DropdownMenuItem( + text = { Text(stringResource(R.string.profile_name)) }, + onClick = { + showContextMenu = false + showCopyMenu = false + onCopyLabel?.invoke() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_action_copy_package_name)) }, + onClick = { + showContextMenu = false + showCopyMenu = false + onCopyPackage?.invoke() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.per_app_proxy_action_copy_uid)) }, + onClick = { + showContextMenu = false + showCopyMenu = false + onCopyUid?.invoke() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp), + ) + }, + ) + } + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt new file mode 100644 index 0000000000..485f443a1f --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Color.kt @@ -0,0 +1,32 @@ +package io.nekohasekai.sfa.compose.theme + +import androidx.compose.ui.graphics.Color + +// Primary colors from existing app +val SingBoxPrimary = Color(0xFFD81B60) +val SingBoxPrimaryDark = Color(0xFFA00037) +val SingBoxPrimaryLight = Color(0xFFFF5C8D) + +// Service status colors +val ServiceRunning = Color(0xFF4CAF50) +val ServiceStopped = Color(0xFF9E9E9E) +val ServiceError = Color(0xFFF44336) + +// Log colors +val LogRed = Color(0xFFFF2158) +val LogGreen = Color(0xFF2ECC71) +val LogYellow = Color(0xFFE5E500) +val LogBlue = Color(0xFF3498DB) +val LogPurple = Color(0xFFE500E5) +val LogRedLight = Color(0xFFE91E63) +val LogBlueLight = Color(0xFF00A6B2) +val LogWhite = Color(0xFFECECEC) + +// Material You seed color +val SeedColor = Color(0xFFD81B60) + +// Additional semantic colors +val SuccessGreen = Color(0xFF4CAF50) +val WarningOrange = Color(0xFFFF9800) +val ErrorRed = Color(0xFFF44336) +val InfoBlue = Color(0xFF2196F3) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt new file mode 100644 index 0000000000..6cd9e04423 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Shape.kt @@ -0,0 +1,14 @@ +package io.nekohasekai.sfa.compose.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +val Shapes = + Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(28.dp), + ) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt new file mode 100644 index 0000000000..34785129cc --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Theme.kt @@ -0,0 +1,69 @@ +package io.nekohasekai.sfa.compose.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = + darkColorScheme( + primary = SingBoxPrimary, + secondary = SingBoxPrimaryLight, + tertiary = LogBlue, + ) + +private val LightColorScheme = + lightColorScheme( + primary = SingBoxPrimary, + secondary = SingBoxPrimaryDark, + tertiary = LogBlue, + ) + +@Composable +fun SFATheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit, +) { + val colorScheme = + when { + dynamicColor && Build.VERSION.SDK_INT >= 31 -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as? Activity)?.window ?: return@SideEffect + window.statusBarColor = colorScheme.surface.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !darkTheme + isAppearanceLightNavigationBars = !darkTheme + } + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + shapes = Shapes, + content = content, + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt new file mode 100644 index 0000000000..48cccc34e2 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/theme/Type.kt @@ -0,0 +1,137 @@ +package io.nekohasekai.sfa.compose.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Material 3 Typography +val Typography = + Typography( + // Display styles + displayLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 57.sp, + lineHeight = 64.sp, + letterSpacing = (-0.25).sp, + ), + displayMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 45.sp, + lineHeight = 52.sp, + letterSpacing = 0.sp, + ), + displaySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 36.sp, + lineHeight = 44.sp, + letterSpacing = 0.sp, + ), + // Headline styles + headlineLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 32.sp, + lineHeight = 40.sp, + letterSpacing = 0.sp, + ), + headlineMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 28.sp, + lineHeight = 36.sp, + letterSpacing = 0.sp, + ), + headlineSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 24.sp, + lineHeight = 32.sp, + letterSpacing = 0.sp, + ), + // Title styles + titleLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp, + ), + titleMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.15.sp, + ), + titleSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + // Body styles + bodyLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp, + ), + bodyMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.25.sp, + ), + bodySmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.4.sp, + ), + // Label styles + labelLarge = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + labelMedium = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + labelSmall = + TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp, + ), + ) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt new file mode 100644 index 0000000000..6c80f2e48b --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/topbar/TopBarController.kt @@ -0,0 +1,37 @@ +package io.nekohasekai.sfa.compose.topbar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState + +internal data class TopBarEntry(val key: Any, val content: @Composable () -> Unit) + +class TopBarController internal constructor(private val state: MutableState>) { + val current: (@Composable () -> Unit)? get() = state.value.lastOrNull()?.content + + fun set(key: Any, content: @Composable () -> Unit) { + state.value = state.value.filterNot { it.key == key } + TopBarEntry(key, content) + } + + fun clear(key: Any) { + state.value = state.value.filterNot { it.key == key } + } +} + +val LocalTopBarController = compositionLocalOf { + error("TopBarController not provided") +} + +@Composable +fun OverrideTopBar(content: @Composable () -> Unit) { + val controller = LocalTopBarController.current + val token = remember { Any() } + val currentContent = rememberUpdatedState(content) + DisposableEffect(controller, token) { + controller.set(token) { currentContent.value() } + onDispose { controller.clear(token) } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt new file mode 100644 index 0000000000..5bd3c7e8d1 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/AnsiColorUtils.kt @@ -0,0 +1,120 @@ +package io.nekohasekai.sfa.compose.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration + +object AnsiColorUtils { + private val ansiRegex = Regex("\u001B\\[[;\\d]*m") + + private val logRed = Color(0xFFFF2158) + private val logGreen = Color(0xFF2ECC71) + private val logYellow = Color(0xFFE5E500) + private val logBlue = Color(0xFF3498DB) + private val logPurple = Color(0xFF9B59B6) + private val logBlueLight = Color(0xFF5DADE2) + private val logWhite = Color(0xFFECF0F1) + + fun ansiToAnnotatedString(text: String): AnnotatedString { + val cleanText = stripAnsi(text) + val matches = ansiRegex.findAll(text).toList() + + if (matches.isEmpty()) { + return AnnotatedString(cleanText) + } + + return buildAnnotatedString { + append(cleanText) + + var currentStyle: SpanStyle? = null + var currentStart = 0 + var offset = 0 + + matches.forEach { match -> + val code = match.value + val codeStart = match.range.first - offset + val decoration = parseAnsiCode(code) + + if (decoration == null) { + // Reset code + if (currentStyle != null && currentStart < codeStart) { + addStyle(currentStyle!!, currentStart, codeStart) + } + currentStyle = null + currentStart = codeStart + } else { + // Apply previous style if exists + if (currentStyle != null && currentStart < codeStart) { + addStyle(currentStyle!!, currentStart, codeStart) + } + currentStyle = decoration + currentStart = codeStart + } + + offset += code.length + } + + // Apply remaining style + if (currentStyle != null && currentStart < cleanText.length) { + addStyle(currentStyle!!, currentStart, cleanText.length) + } + } + } + + fun stripAnsi(text: String): String = text.replace(ansiRegex, "") + + private fun parseAnsiCode(code: String): SpanStyle? { + val colorCodes = code.substringAfter('[').substringBefore('m').split(';') + + var color: Color? = null + var fontWeight: FontWeight? = null + var fontStyle: FontStyle? = null + var textDecoration: TextDecoration? = null + + colorCodes.forEach { codeStr -> + when (codeStr) { + "0" -> return null // Reset + "1" -> fontWeight = FontWeight.Bold + "3" -> fontStyle = FontStyle.Italic + "4" -> textDecoration = TextDecoration.Underline + "30" -> color = Color.Black + "31" -> color = logRed + "32" -> color = logGreen + "33" -> color = logYellow + "34" -> color = logBlue + "35" -> color = logPurple + "36" -> color = logBlueLight + "37" -> color = logWhite + else -> { + val codeInt = codeStr.toIntOrNull() + if (codeInt != null && codeInt in 38..125) { + val adjustedCode = codeInt % 125 + val row = adjustedCode / 36 + val column = adjustedCode % 36 + color = + Color( + red = row * 51, + green = (column / 6) * 51, + blue = (column % 6) * 51, + ) + } + } + } + } + + return if (color != null || fontWeight != null || fontStyle != null || textDecoration != null) { + SpanStyle( + color = color ?: Color.Unspecified, + fontWeight = fontWeight, + fontStyle = fontStyle, + textDecoration = textDecoration, + ) + } else { + null + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt new file mode 100644 index 0000000000..b6bb873c3c --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/MaterialIconsLibrary.kt @@ -0,0 +1,434 @@ +package io.nekohasekai.sfa.compose.util + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.* +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.material.icons.rounded.* +import androidx.compose.material.icons.sharp.* +import androidx.compose.material.icons.twotone.* +import androidx.compose.ui.graphics.vector.ImageVector + +data class IconCategory(val name: String, val icons: List) + +object MaterialIconsLibrary { + val categories = + listOf( + IconCategory( + "Security & Privacy", + listOf( + ProfileIcon("shield", Icons.Filled.Shield, "Shield"), + ProfileIcon("security", Icons.Filled.Security, "Security"), + ProfileIcon("lock", Icons.Filled.Lock, "Lock"), + ProfileIcon("lock_open", Icons.Filled.LockOpen, "Lock Open"), + ProfileIcon("vpn_key", Icons.Filled.VpnKey, "VPN Key"), + ProfileIcon("vpn_lock", Icons.Filled.VpnLock, "VPN Lock"), + ProfileIcon("key", Icons.Filled.Key, "Key"), + ProfileIcon("password", Icons.Filled.Password, "Password"), + ProfileIcon("fingerprint", Icons.Filled.Fingerprint, "Fingerprint"), + ProfileIcon("verified_user", Icons.Filled.VerifiedUser, "Verified"), + ProfileIcon("privacy_tip", Icons.Filled.PrivacyTip, "Privacy"), + ProfileIcon("admin_panel", Icons.Filled.AdminPanelSettings, "Admin"), + ProfileIcon("policy", Icons.Filled.Policy, "Policy"), + ProfileIcon("gpp_good", Icons.Filled.GppGood, "Protected"), + ProfileIcon("gpp_maybe", Icons.Filled.GppMaybe, "Maybe Protected"), + ProfileIcon("enhanced_encryption", Icons.Filled.EnhancedEncryption, "Encryption"), + ProfileIcon("no_encryption", Icons.Filled.NoEncryption, "No Encryption"), + ProfileIcon("https", Icons.Filled.Https, "HTTPS"), + ProfileIcon("http", Icons.Filled.Http, "HTTP"), + ProfileIcon("safety_check", Icons.Filled.SafetyCheck, "Safety Check"), + ), + ), + IconCategory( + "Network & Connection", + listOf( + ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"), + ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"), + ProfileIcon("wifi_lock", Icons.Filled.WifiLock, "WiFi Lock"), + ProfileIcon("wifi_tethering", Icons.Filled.WifiTethering, "Tethering"), + ProfileIcon("signal_wifi_4_bar", Icons.Filled.SignalWifi4Bar, "Strong WiFi"), + ProfileIcon("signal_wifi_bad", Icons.Filled.SignalWifiBad, "Bad WiFi"), + ProfileIcon("router", Icons.Filled.Router, "Router"), + ProfileIcon("network_check", Icons.Filled.NetworkCheck, "Network Check"), + ProfileIcon("network_locked", Icons.Filled.NetworkLocked, "Network Locked"), + ProfileIcon("network_ping", Icons.Filled.NetworkPing, "Network Ping"), + ProfileIcon("hub", Icons.Filled.Hub, "Hub"), + ProfileIcon("dns", Icons.Filled.Dns, "DNS"), + ProfileIcon("lan", Icons.Filled.Lan, "LAN"), + ProfileIcon("cable", Icons.Filled.Cable, "Cable"), + ProfileIcon("settings_ethernet", Icons.Filled.SettingsEthernet, "Ethernet"), + ProfileIcon("cell_tower", Icons.Filled.CellTower, "Cell Tower"), + ProfileIcon("cell_wifi", Icons.Filled.CellWifi, "Cell WiFi"), + ProfileIcon("signal_cellular_4_bar", Icons.Filled.SignalCellular4Bar, "4G"), + ProfileIcon("signal_cellular_alt", Icons.Filled.SignalCellularAlt, "Cellular"), + // Some newer icons might not be available in all versions + // ProfileIcon("5g", Icons.Filled.FiveG, "5G"), + // ProfileIcon("4g_mobiledata", Icons.Filled.FourGMobiledata, "4G"), + // ProfileIcon("lte_mobiledata", Icons.Filled.LteMobiledata, "LTE") + ), + ), + IconCategory( + "Global & Cloud", + listOf( + ProfileIcon("language", Icons.Filled.Language, "Globe"), + ProfileIcon("public", Icons.Filled.Public, "Public"), + ProfileIcon("public_off", Icons.Filled.PublicOff, "Public Off"), + ProfileIcon("travel_explore", Icons.Filled.TravelExplore, "Explore"), + ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"), + ProfileIcon("cloud_upload", Icons.Filled.CloudUpload, "Cloud Upload"), + ProfileIcon("cloud_download", Icons.Filled.CloudDownload, "Cloud Download"), + ProfileIcon("cloud_sync", Icons.Filled.CloudSync, "Cloud Sync"), + ProfileIcon("cloud_done", Icons.Filled.CloudDone, "Cloud Done"), + ProfileIcon("cloud_off", Icons.Filled.CloudOff, "Cloud Off"), + ProfileIcon("cloud_queue", Icons.Filled.CloudQueue, "Cloud Queue"), + ProfileIcon("backup", Icons.Filled.Backup, "Backup"), + ProfileIcon("satellite", Icons.Filled.Satellite, "Satellite"), + ProfileIcon("satellite_alt", Icons.Filled.SatelliteAlt, "Satellite Alt"), + ProfileIcon("share", Icons.Filled.Share, "Share"), + ProfileIcon("share_location", Icons.Filled.ShareLocation, "Share Location"), + ProfileIcon("sync", Icons.Filled.Sync, "Sync"), + ProfileIcon("sync_alt", Icons.Filled.SyncAlt, "Sync Alt"), + ), + ), + IconCategory( + "Devices", + listOf( + ProfileIcon("computer", Icons.Filled.Computer, "Computer"), + ProfileIcon("desktop_windows", Icons.Filled.DesktopWindows, "Desktop"), + ProfileIcon("laptop", Icons.Filled.Laptop, "Laptop"), + ProfileIcon("laptop_chromebook", Icons.Filled.LaptopChromebook, "Chromebook"), + ProfileIcon("laptop_mac", Icons.Filled.LaptopMac, "MacBook"), + ProfileIcon("laptop_windows", Icons.Filled.LaptopWindows, "Windows Laptop"), + ProfileIcon("smartphone", Icons.Filled.Smartphone, "Phone"), + ProfileIcon("phone_android", Icons.Filled.PhoneAndroid, "Android"), + ProfileIcon("phone_iphone", Icons.Filled.PhoneIphone, "iPhone"), + ProfileIcon("tablet", Icons.Filled.Tablet, "Tablet"), + ProfileIcon("tablet_android", Icons.Filled.TabletAndroid, "Android Tablet"), + ProfileIcon("tablet_mac", Icons.Filled.TabletMac, "iPad"), + ProfileIcon("watch", Icons.Filled.Watch, "Watch"), + ProfileIcon("tv", Icons.Filled.Tv, "TV"), + ProfileIcon("smart_display", Icons.Filled.SmartDisplay, "Smart Display"), + ProfileIcon("speaker", Icons.Filled.Speaker, "Speaker"), + ProfileIcon("headphones", Icons.Filled.Headphones, "Headphones"), + ProfileIcon("devices", Icons.Filled.Devices, "Devices"), + ProfileIcon("device_hub", Icons.Filled.DeviceHub, "Device Hub"), + ProfileIcon("cast", Icons.Filled.Cast, "Cast"), + ProfileIcon("cast_connected", Icons.Filled.CastConnected, "Cast Connected"), + ), + ), + IconCategory( + "Places & Activities", + listOf( + ProfileIcon("home", Icons.Filled.Home, "Home"), + ProfileIcon("house", Icons.Filled.House, "House"), + ProfileIcon("cabin", Icons.Filled.Cabin, "Cabin"), + ProfileIcon("apartment", Icons.Filled.Apartment, "Apartment"), + ProfileIcon("work", Icons.Filled.Work, "Work"), + ProfileIcon("work_outline", Icons.Outlined.Work, "Work Outline"), + ProfileIcon("business", Icons.Filled.Business, "Business"), + ProfileIcon("business_center", Icons.Filled.BusinessCenter, "Business Center"), + ProfileIcon("school", Icons.Filled.School, "School"), + ProfileIcon("local_library", Icons.Filled.LocalLibrary, "Library"), + ProfileIcon("store", Icons.Filled.Store, "Store"), + ProfileIcon("storefront", Icons.Filled.Storefront, "Storefront"), + ProfileIcon("restaurant", Icons.Filled.Restaurant, "Restaurant"), + ProfileIcon("coffee", Icons.Filled.Coffee, "Coffee"), + ProfileIcon("local_cafe", Icons.Filled.LocalCafe, "Cafe"), + ProfileIcon("hotel", Icons.Filled.Hotel, "Hotel"), + ProfileIcon("flight", Icons.Filled.Flight, "Flight"), + ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Takeoff"), + ProfileIcon("flight_land", Icons.Filled.FlightLand, "Landing"), + ProfileIcon("train", Icons.Filled.Train, "Train"), + ProfileIcon("directions_car", Icons.Filled.DirectionsCar, "Car"), + ProfileIcon("directions_bus", Icons.Filled.DirectionsBus, "Bus"), + ProfileIcon("directions_subway", Icons.Filled.DirectionsSubway, "Subway"), + ProfileIcon("beach_access", Icons.Filled.BeachAccess, "Beach"), + ProfileIcon("park", Icons.Filled.Park, "Park"), + ProfileIcon("fitness_center", Icons.Filled.FitnessCenter, "Gym"), + ProfileIcon("sports_esports", Icons.Filled.SportsEsports, "Gaming"), + ProfileIcon("stadium", Icons.Filled.Stadium, "Stadium"), + ), + ), + IconCategory( + "Communication", + listOf( + ProfileIcon("email", Icons.Filled.Email, "Email"), + ProfileIcon("mail", Icons.Filled.Mail, "Mail"), + ProfileIcon("message", Icons.Filled.Message, "Message"), + ProfileIcon("chat", Icons.Filled.Chat, "Chat"), + ProfileIcon("chat_bubble", Icons.Filled.ChatBubble, "Chat Bubble"), + ProfileIcon("forum", Icons.Filled.Forum, "Forum"), + ProfileIcon("comment", Icons.Filled.Comment, "Comment"), + ProfileIcon("call", Icons.Filled.Call, "Call"), + ProfileIcon("video_call", Icons.Filled.VideoCall, "Video Call"), + ProfileIcon("contacts", Icons.Filled.Contacts, "Contacts"), + ProfileIcon("contact_mail", Icons.Filled.ContactMail, "Contact Mail"), + ProfileIcon("contact_phone", Icons.Filled.ContactPhone, "Contact Phone"), + ProfileIcon("notifications", Icons.Filled.Notifications, "Notifications"), + ProfileIcon("notifications_active", Icons.Filled.NotificationsActive, "Active Notif"), + ProfileIcon("notification_important", Icons.Filled.NotificationImportant, "Important"), + ProfileIcon("announcement", Icons.Filled.Announcement, "Announcement"), + ), + ), + IconCategory( + "Media & Entertainment", + listOf( + ProfileIcon("play_arrow", Icons.Filled.PlayArrow, "Play"), + ProfileIcon("play_circle", Icons.Filled.PlayCircle, "Play Circle"), + ProfileIcon("pause", Icons.Filled.Pause, "Pause"), + ProfileIcon("pause_circle", Icons.Filled.PauseCircle, "Pause Circle"), + ProfileIcon("stop", Icons.Filled.Stop, "Stop"), + ProfileIcon("skip_next", Icons.Filled.SkipNext, "Next"), + ProfileIcon("skip_previous", Icons.Filled.SkipPrevious, "Previous"), + ProfileIcon("music_note", Icons.Filled.MusicNote, "Music"), + ProfileIcon("audiotrack", Icons.Filled.Audiotrack, "Audio"), + ProfileIcon("album", Icons.Filled.Album, "Album"), + ProfileIcon("mic", Icons.Filled.Mic, "Microphone"), + ProfileIcon("videocam", Icons.Filled.Videocam, "Video"), + ProfileIcon("movie", Icons.Filled.Movie, "Movie"), + ProfileIcon("theaters", Icons.Filled.Theaters, "Theater"), + ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"), + ProfileIcon("photo", Icons.Filled.Photo, "Photo"), + ProfileIcon("photo_camera", Icons.Filled.PhotoCamera, "Camera"), + ProfileIcon("photo_library", Icons.Filled.PhotoLibrary, "Gallery"), + ProfileIcon("games", Icons.Filled.Games, "Games"), + ProfileIcon("sports_soccer", Icons.Filled.SportsSoccer, "Soccer"), + ProfileIcon("sports_basketball", Icons.Filled.SportsBasketball, "Basketball"), + ProfileIcon("sports_football", Icons.Filled.SportsFootball, "Football"), + ), + ), + IconCategory( + "Files & Folders", + listOf( + ProfileIcon("folder", Icons.Filled.Folder, "Folder"), + ProfileIcon("folder_open", Icons.Filled.FolderOpen, "Folder Open"), + ProfileIcon("folder_shared", Icons.Filled.FolderShared, "Shared Folder"), + ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Special Folder"), + ProfileIcon("create_new_folder", Icons.Filled.CreateNewFolder, "New Folder"), + ProfileIcon("insert_drive_file", Icons.AutoMirrored.Filled.InsertDriveFile, "File"), + ProfileIcon("description", Icons.Filled.Description, "Document"), + ProfileIcon("article", Icons.AutoMirrored.Filled.Article, "Article"), + ProfileIcon("picture_as_pdf", Icons.Filled.PictureAsPdf, "PDF"), + ProfileIcon("attach_file", Icons.Filled.AttachFile, "Attachment"), + ProfileIcon("file_download", Icons.Filled.FileDownload, "Download"), + ProfileIcon("file_upload", Icons.Filled.FileUpload, "Upload"), + ProfileIcon("file_copy", Icons.Filled.FileCopy, "Copy"), + ProfileIcon("content_copy", Icons.Filled.ContentCopy, "Copy Content"), + ProfileIcon("content_paste", Icons.Filled.ContentPaste, "Paste"), + ProfileIcon("save", Icons.Filled.Save, "Save"), + ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"), + ProfileIcon("archive", Icons.Filled.Archive, "Archive"), + ProfileIcon("inventory", Icons.Filled.Inventory, "Inventory"), + ProfileIcon("storage", Icons.Filled.Storage, "Storage"), + ), + ), + IconCategory( + "Actions & Tools", + listOf( + ProfileIcon("settings", Icons.Filled.Settings, "Settings"), + ProfileIcon("build", Icons.Filled.Build, "Build"), + ProfileIcon("extension", Icons.Filled.Extension, "Extension"), + ProfileIcon("search", Icons.Filled.Search, "Search"), + ProfileIcon("zoom_in", Icons.Filled.ZoomIn, "Zoom In"), + ProfileIcon("zoom_out", Icons.Filled.ZoomOut, "Zoom Out"), + ProfileIcon("info", Icons.Filled.Info, "Info"), + ProfileIcon("help", Icons.Filled.Help, "Help"), + ProfileIcon("help_center", Icons.Filled.HelpCenter, "Help Center"), + ProfileIcon("explore", Icons.Filled.Explore, "Explore"), + ProfileIcon("bookmark", Icons.Filled.Bookmark, "Bookmark"), + ProfileIcon("bookmarks", Icons.Filled.Bookmarks, "Bookmarks"), + ProfileIcon("history", Icons.Filled.History, "History"), + ProfileIcon("schedule", Icons.Filled.Schedule, "Schedule"), + ProfileIcon("alarm", Icons.Filled.Alarm, "Alarm"), + ProfileIcon("timer", Icons.Filled.Timer, "Timer"), + ProfileIcon("update", Icons.Filled.Update, "Update"), + ProfileIcon("upgrade", Icons.Filled.Upgrade, "Upgrade"), + ProfileIcon("autorenew", Icons.Filled.Autorenew, "Auto Renew"), + ProfileIcon("cached", Icons.Filled.Cached, "Cached"), + ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"), + ProfileIcon("sync_problem", Icons.Filled.SyncProblem, "Sync Problem"), + ProfileIcon("download", Icons.Filled.Download, "Download"), + ProfileIcon("upload", Icons.Filled.Upload, "Upload"), + ProfileIcon("print", Icons.Filled.Print, "Print"), + ProfileIcon("delete", Icons.Filled.Delete, "Delete"), + ), + ), + IconCategory( + "Status & Indicators", + listOf( + ProfileIcon("check", Icons.Filled.Check, "Check"), + ProfileIcon("check_circle", Icons.Filled.CheckCircle, "Check Circle"), + ProfileIcon("verified", Icons.Filled.Verified, "Verified"), + ProfileIcon("done", Icons.Filled.Done, "Done"), + ProfileIcon("done_all", Icons.Filled.DoneAll, "Done All"), + ProfileIcon("close", Icons.Filled.Close, "Close"), + ProfileIcon("cancel", Icons.Filled.Cancel, "Cancel"), + ProfileIcon("error", Icons.Filled.Error, "Error"), + ProfileIcon("warning", Icons.Filled.Warning, "Warning"), + ProfileIcon("report", Icons.Filled.Report, "Report"), + ProfileIcon("flag", Icons.Filled.Flag, "Flag"), + ProfileIcon("star", Icons.Filled.Star, "Star"), + ProfileIcon("star_half", Icons.Filled.StarHalf, "Half Star"), + ProfileIcon("star_outline", Icons.Filled.StarOutline, "Star Outline"), + ProfileIcon("favorite", Icons.Filled.Favorite, "Favorite"), + ProfileIcon("favorite_border", Icons.Filled.FavoriteBorder, "Favorite Border"), + ProfileIcon("thumb_up", Icons.Filled.ThumbUp, "Like"), + ProfileIcon("thumb_down", Icons.Filled.ThumbDown, "Dislike"), + ProfileIcon("priority_high", Icons.Filled.PriorityHigh, "High Priority"), + ProfileIcon("new_releases", Icons.Filled.NewReleases, "New"), + ProfileIcon("fiber_new", Icons.Filled.FiberNew, "New Badge"), + ProfileIcon("offline_pin", Icons.Filled.OfflinePin, "Offline"), + ProfileIcon("online_prediction", Icons.Filled.OnlinePrediction, "Online"), + ), + ), + IconCategory( + "Nature & Weather", + listOf( + ProfileIcon("wb_sunny", Icons.Filled.WbSunny, "Sunny"), + ProfileIcon("nights_stay", Icons.Filled.NightsStay, "Night"), + ProfileIcon("brightness_high", Icons.Filled.BrightnessHigh, "Bright"), + ProfileIcon("wb_cloudy", Icons.Filled.WbCloudy, "Cloudy"), + ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"), + ProfileIcon("ac_unit", Icons.Filled.AcUnit, "Snow"), + ProfileIcon("thunderstorm", Icons.Filled.Thunderstorm, "Storm"), + ProfileIcon("water_drop", Icons.Filled.WaterDrop, "Water"), + ProfileIcon("waves", Icons.Filled.Waves, "Waves"), + ProfileIcon("eco", Icons.Filled.Eco, "Eco"), + ProfileIcon("nature", Icons.Filled.Nature, "Nature"), + ProfileIcon("nature_people", Icons.Filled.NaturePeople, "Nature People"), + ProfileIcon("forest", Icons.Filled.Forest, "Forest"), + ProfileIcon("grass", Icons.Filled.Grass, "Grass"), + ProfileIcon("local_florist", Icons.Filled.LocalFlorist, "Flower"), + ProfileIcon("pets", Icons.Filled.Pets, "Pets"), + ProfileIcon("bug_report", Icons.Filled.BugReport, "Bug"), + ProfileIcon("spa", Icons.Filled.Spa, "Spa"), + ProfileIcon("pool", Icons.Filled.Pool, "Pool"), + ProfileIcon("hot_tub", Icons.Filled.HotTub, "Hot Tub"), + ), + ), + IconCategory( + "Transportation", + listOf( + ProfileIcon("local_shipping", Icons.Filled.LocalShipping, "Shipping"), + ProfileIcon("local_taxi", Icons.Filled.LocalTaxi, "Taxi"), + ProfileIcon("directions_bike", Icons.Filled.DirectionsBike, "Bike"), + ProfileIcon("directions_boat", Icons.Filled.DirectionsBoat, "Boat"), + ProfileIcon("directions_railway", Icons.Filled.DirectionsRailway, "Railway"), + ProfileIcon("directions_transit", Icons.Filled.DirectionsTransit, "Transit"), + ProfileIcon("directions_walk", Icons.Filled.DirectionsWalk, "Walk"), + ProfileIcon("directions_run", Icons.Filled.DirectionsRun, "Run"), + ProfileIcon("electric_car", Icons.Filled.ElectricCar, "Electric Car"), + ProfileIcon("electric_bike", Icons.Filled.ElectricBike, "E-Bike"), + ProfileIcon("electric_scooter", Icons.Filled.ElectricScooter, "E-Scooter"), + ProfileIcon("two_wheeler", Icons.Filled.TwoWheeler, "Two Wheeler"), + ProfileIcon("motorcycle", Icons.Filled.Motorcycle, "Motorcycle"), + ProfileIcon("airport_shuttle", Icons.Filled.AirportShuttle, "Shuttle"), + ProfileIcon("commute", Icons.Filled.Commute, "Commute"), + ProfileIcon("rocket", Icons.Filled.Rocket, "Rocket"), + ProfileIcon("rocket_launch", Icons.Filled.RocketLaunch, "Rocket Launch"), + ProfileIcon("sailing", Icons.Filled.Sailing, "Sailing"), + ), + ), + IconCategory( + "Shopping & Finance", + listOf( + ProfileIcon("shopping_cart", Icons.Filled.ShoppingCart, "Cart"), + ProfileIcon("shopping_bag", Icons.Filled.ShoppingBag, "Shopping Bag"), + ProfileIcon("shopping_basket", Icons.Filled.ShoppingBasket, "Basket"), + ProfileIcon("add_shopping_cart", Icons.Filled.AddShoppingCart, "Add to Cart"), + ProfileIcon("local_mall", Icons.Filled.LocalMall, "Mall"), + ProfileIcon("local_grocery_store", Icons.Filled.LocalGroceryStore, "Grocery"), + ProfileIcon("payment", Icons.Filled.Payment, "Payment"), + ProfileIcon("credit_card", Icons.Filled.CreditCard, "Credit Card"), + ProfileIcon("account_balance", Icons.Filled.AccountBalance, "Bank"), + ProfileIcon("account_balance_wallet", Icons.Filled.AccountBalanceWallet, "Wallet"), + ProfileIcon("wallet", Icons.Filled.Wallet, "Wallet"), + ProfileIcon("savings", Icons.Filled.Savings, "Savings"), + ProfileIcon("attach_money", Icons.Filled.AttachMoney, "Money"), + ProfileIcon("money", Icons.Filled.Money, "Cash"), + ProfileIcon("paid", Icons.Filled.Paid, "Paid"), + ProfileIcon("currency_bitcoin", Icons.Filled.CurrencyBitcoin, "Bitcoin"), + ProfileIcon("currency_exchange", Icons.Filled.CurrencyExchange, "Exchange"), + ProfileIcon("receipt", Icons.Filled.Receipt, "Receipt"), + ProfileIcon("receipt_long", Icons.Filled.ReceiptLong, "Receipt Long"), + ProfileIcon("sell", Icons.Filled.Sell, "Sell"), + ProfileIcon("discount", Icons.Filled.Discount, "Discount"), + ProfileIcon("redeem", Icons.Filled.Redeem, "Redeem"), + ), + ), + IconCategory( + "Health & Wellness", + listOf( + ProfileIcon("medical_services", Icons.Filled.MedicalServices, "Medical"), + ProfileIcon("medication", Icons.Filled.Medication, "Medication"), + ProfileIcon("vaccines", Icons.Filled.Vaccines, "Vaccine"), + ProfileIcon("healing", Icons.Filled.Healing, "Healing"), + ProfileIcon("health_and_safety", Icons.Filled.HealthAndSafety, "Health & Safety"), + ProfileIcon("local_hospital", Icons.Filled.LocalHospital, "Hospital"), + ProfileIcon("local_pharmacy", Icons.Filled.LocalPharmacy, "Pharmacy"), + ProfileIcon("monitor_heart", Icons.Filled.MonitorHeart, "Heart Monitor"), + ProfileIcon("bloodtype", Icons.Filled.Bloodtype, "Blood Type"), + ProfileIcon("emergency", Icons.Filled.Emergency, "Emergency"), + ProfileIcon("medical_information", Icons.Filled.MedicalInformation, "Medical Info"), + ProfileIcon("psychology", Icons.Filled.Psychology, "Psychology"), + ProfileIcon("self_improvement", Icons.Filled.SelfImprovement, "Self Improvement"), + ProfileIcon("mood", Icons.Filled.Mood, "Happy"), + ProfileIcon("mood_bad", Icons.Filled.MoodBad, "Sad"), + ProfileIcon("sentiment_satisfied", Icons.Filled.SentimentSatisfied, "Satisfied"), + ProfileIcon("sentiment_dissatisfied", Icons.Filled.SentimentDissatisfied, "Dissatisfied"), + ProfileIcon("sick", Icons.Filled.Sick, "Sick"), + ProfileIcon("masks", Icons.Filled.Masks, "Masks"), + ProfileIcon("sanitizer", Icons.Filled.Sanitizer, "Sanitizer"), + ProfileIcon("clean_hands", Icons.Filled.CleanHands, "Clean Hands"), + ProfileIcon("coronavirus", Icons.Filled.Coronavirus, "Virus"), + ), + ), + IconCategory( + "Food & Dining", + listOf( + ProfileIcon("restaurant_menu", Icons.Filled.RestaurantMenu, "Menu"), + ProfileIcon("fastfood", Icons.Filled.Fastfood, "Fast Food"), + ProfileIcon("lunch_dining", Icons.Filled.LunchDining, "Lunch"), + ProfileIcon("dinner_dining", Icons.Filled.DinnerDining, "Dinner"), + ProfileIcon("breakfast_dining", Icons.Filled.BreakfastDining, "Breakfast"), + ProfileIcon("brunch_dining", Icons.Filled.BrunchDining, "Brunch"), + ProfileIcon("bakery_dining", Icons.Filled.BakeryDining, "Bakery"), + ProfileIcon("icecream", Icons.Filled.Icecream, "Ice Cream"), + ProfileIcon("cake", Icons.Filled.Cake, "Cake"), + ProfileIcon("local_pizza", Icons.Filled.LocalPizza, "Pizza"), + ProfileIcon("local_bar", Icons.Filled.LocalBar, "Bar"), + ProfileIcon("local_drink", Icons.Filled.LocalDrink, "Drink"), + ProfileIcon("liquor", Icons.Filled.Liquor, "Liquor"), + ProfileIcon("wine_bar", Icons.Filled.WineBar, "Wine"), + ProfileIcon("sports_bar", Icons.Filled.SportsBar, "Sports Bar"), + ProfileIcon("kitchen", Icons.Filled.Kitchen, "Kitchen"), + ProfileIcon("dining", Icons.Filled.Dining, "Dining"), + ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"), + ProfileIcon("ramen_dining", Icons.Filled.RamenDining, "Ramen"), + ProfileIcon("rice_bowl", Icons.Filled.RiceBowl, "Rice Bowl"), + ProfileIcon("soup_kitchen", Icons.Filled.SoupKitchen, "Soup"), + ProfileIcon("takeout_dining", Icons.Filled.TakeoutDining, "Takeout"), + ProfileIcon("delivery_dining", Icons.Filled.DeliveryDining, "Delivery"), + ), + ), + ) + + fun getAllIcons(): List = categories.flatMap { it.icons } + + fun getIconById(id: String?): ImageVector? { + if (id == null) return null + return getAllIcons().find { it.id == id }?.icon + } + + fun getCategoryForIcon(iconId: String): String? = categories.find { category -> + category.icons.any { it.id == iconId } + }?.name + + fun searchIcons(query: String): List { + val lowercaseQuery = query.lowercase() + return getAllIcons().filter { icon -> + icon.id.contains(lowercaseQuery) || + icon.label.lowercase().contains(lowercaseQuery) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt new file mode 100644 index 0000000000..6ec38ace2b --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/ProfileIcons.kt @@ -0,0 +1,30 @@ +package io.nekohasekai.sfa.compose.util + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.ui.graphics.vector.ImageVector +import io.nekohasekai.sfa.compose.util.icons.MaterialIconsLibrary + +data class ProfileIcon(val id: String, val icon: ImageVector, val label: String) + +object ProfileIcons { + // Use the complete Material Icons library with all available icons + val availableIcons: List + get() = MaterialIconsLibrary.getAllIcons() + + fun getIconById(id: String?): ImageVector? { + if (id == null) return null + return MaterialIconsLibrary.getIconById(id) + } + + fun getDefaultIconForType(isRemote: Boolean): ImageVector { + // Use the same default icon for all profile types + return Icons.AutoMirrored.Default.InsertDriveFile + } + + fun getCategoryForIcon(iconId: String): String? = MaterialIconsLibrary.getCategoryForIcon(iconId) + + fun searchIcons(query: String): List = MaterialIconsLibrary.searchIcons(query) + + fun getCategories() = MaterialIconsLibrary.categories +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt new file mode 100644 index 0000000000..e1f717e2f4 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/QRCodeGenerator.kt @@ -0,0 +1,114 @@ +package io.nekohasekai.sfa.compose.util + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.toArgb +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter + +object QRCodeGenerator { + + private fun luminance(color: Int): Float { + val r = Color.red(color) / 255f + val g = Color.green(color) / 255f + val b = Color.blue(color) / 255f + return 0.299f * r + 0.587f * g + 0.114f * b + } + + private fun adjustBrightness(color: Int, factor: Float): Int { + val a = Color.alpha(color) + val r = (Color.red(color) * factor).toInt().coerceIn(0, 255) + val g = (Color.green(color) * factor).toInt().coerceIn(0, 255) + val b = (Color.blue(color) * factor).toInt().coerceIn(0, 255) + return Color.argb(a, r, g, b) + } + + fun ensureContrast(foreground: Int, background: Int, minRatio: Float = 4.5f): Int { + val bgLum = luminance(background) + var fg = foreground + var fgLum = luminance(fg) + + var ratio = if (fgLum > bgLum) { + (fgLum + 0.05f) / (bgLum + 0.05f) + } else { + (bgLum + 0.05f) / (fgLum + 0.05f) + } + + if (ratio >= minRatio) return fg + + val shouldDarken = bgLum > 0.5f + repeat(10) { + fg = if (shouldDarken) { + adjustBrightness(fg, 0.8f) + } else { + adjustBrightness(fg, 1.25f) + } + fgLum = luminance(fg) + ratio = if (fgLum > bgLum) { + (fgLum + 0.05f) / (bgLum + 0.05f) + } else { + (bgLum + 0.05f) / (fgLum + 0.05f) + } + if (ratio >= minRatio) return fg + } + return fg + } + + @Composable + fun rememberBitmap(content: String, size: Int = 512): Bitmap { + val isDarkTheme = isSystemInDarkTheme() + return remember(content, isDarkTheme) { + generate( + content = content, + size = size, + foregroundColor = if (isDarkTheme) Color.WHITE else Color.BLACK, + backgroundColor = Color.TRANSPARENT, + ) + } + } + + @Composable + fun rememberPrimaryBitmap(content: String, size: Int = 512, backgroundColor: Int): Bitmap { + val primaryColor = MaterialTheme.colorScheme.primary.toArgb() + val safeColor = remember(primaryColor, backgroundColor) { + ensureContrast(primaryColor, backgroundColor) + } + return remember(content, safeColor) { + generate( + content = content, + size = size, + foregroundColor = safeColor, + backgroundColor = Color.TRANSPARENT, + ) + } + } + + fun generate(content: String, size: Int = 512, foregroundColor: Int = Color.BLACK, backgroundColor: Int = Color.WHITE): Bitmap { + val writer = QRCodeWriter() + val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, size, size) + + val width = bitMatrix.width + val height = bitMatrix.height + val pixels = IntArray(width * height) + + for (y in 0 until height) { + val offset = y * width + for (x in 0 until width) { + pixels[offset + x] = + if (bitMatrix.get(x, y)) { + foregroundColor + } else { + backgroundColor + } + } + } + + return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).apply { + setPixels(pixels, 0, width, 0, 0, width, height) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt new file mode 100644 index 0000000000..3513a6c402 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/RelativeTimeFormatter.kt @@ -0,0 +1,92 @@ +package io.nekohasekai.sfa.compose.util + +import android.content.Context +import io.nekohasekai.sfa.R +import java.text.DateFormat +import java.util.Date +import java.util.concurrent.TimeUnit + +object RelativeTimeFormatter { + /** + * Formats a date as relative time for recent dates (within 7 days) + * or as full date/time for older dates. + */ + fun format(context: Context, date: Date?): String { + if (date == null) return "" + + val now = System.currentTimeMillis() + val diff = now - date.time + + // Handle negative differences (future dates) + if (diff < 0) { + return DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) + } + + val seconds = TimeUnit.MILLISECONDS.toSeconds(diff) + val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) + val hours = TimeUnit.MILLISECONDS.toHours(diff) + val days = TimeUnit.MILLISECONDS.toDays(diff) + + return when { + seconds < 60 -> context.getString(R.string.time_just_now) + minutes < 60 -> + context.resources.getQuantityString( + R.plurals.time_minutes_ago, + minutes.toInt(), + minutes, + ) + hours < 24 -> + context.resources.getQuantityString( + R.plurals.time_hours_ago, + hours.toInt(), + hours, + ) + days == 1L -> context.getString(R.string.time_yesterday) + days < 7 -> + context.resources.getQuantityString( + R.plurals.time_days_ago, + days.toInt(), + days, + ) + else -> DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(date) + } + } + + /** + * Formats a date as short relative time for compact displays. + * Uses shorter format like "2h" instead of "2 hours ago". + */ + fun formatShort(context: Context, date: Date?): String { + if (date == null) return "" + + val now = System.currentTimeMillis() + val diff = now - date.time + + // Handle negative differences (future dates) + if (diff < 0) { + return DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date) + } + + val seconds = TimeUnit.MILLISECONDS.toSeconds(diff) + val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) + val hours = TimeUnit.MILLISECONDS.toHours(diff) + val days = TimeUnit.MILLISECONDS.toDays(diff) + + return when { + seconds < 60 -> context.getString(R.string.time_now) + minutes < 60 -> context.getString(R.string.time_minutes_short, minutes) + hours < 24 -> context.getString(R.string.time_hours_short, hours) + days == 1L -> context.getString(R.string.time_yesterday_short) + days < 7 -> context.getString(R.string.time_days_short, days) + else -> DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(date) + } + } + + /** + * Gets the exact date/time string for tooltips or detailed views. + */ + fun formatExact(date: Date?): String { + if (date == null) return "" + return DateFormat.getDateTimeInstance(DateFormat.FULL, DateFormat.MEDIUM).format(date) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt new file mode 100644 index 0000000000..3be3768aff --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/SheetNestedScroll.kt @@ -0,0 +1,58 @@ +package io.nekohasekai.sfa.compose.util + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Velocity + +@Composable +fun rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier(isAtTop: () -> Boolean): Modifier { + val isAtTopState = rememberUpdatedState(isAtTop) + val gestureStartedAtTop = remember { mutableStateOf(true) } + + val nestedScrollConnection = + remember { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + val startedAtTop = gestureStartedAtTop.value + return when { + available.y < 0 -> available + available.y > 0 && !startedAtTop -> available + else -> Offset.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val startedAtTop = gestureStartedAtTop.value + return when { + available.y < 0 -> available + available.y > 0 && !startedAtTop -> available + else -> Velocity.Zero + } + } + } + } + + val gestureGateModifier = + Modifier.pointerInput(Unit) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + gestureStartedAtTop.value = isAtTopState.value.invoke() + do { + val event = awaitPointerEvent() + } while (event.changes.any { it.pressed }) + } + } + + return gestureGateModifier.nestedScroll(nestedScrollConnection) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt new file mode 100644 index 0000000000..46fd80b48d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AVIcons.kt @@ -0,0 +1,306 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.FeaturedPlayList +import androidx.compose.material.icons.automirrored.filled.FeaturedVideo +import androidx.compose.material.icons.automirrored.filled.Note +import androidx.compose.material.icons.automirrored.filled.QueueMusic +import androidx.compose.material.icons.automirrored.filled.VolumeDown +import androidx.compose.material.icons.automirrored.filled.VolumeMute +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.filled.AddToQueue +import androidx.compose.material.icons.filled.Airplay +import androidx.compose.material.icons.filled.Album +import androidx.compose.material.icons.filled.ArtTrack +import androidx.compose.material.icons.filled.AudioFile +import androidx.compose.material.icons.filled.AvTimer +import androidx.compose.material.icons.filled.BrandingWatermark +import androidx.compose.material.icons.filled.CallToAction +import androidx.compose.material.icons.filled.ClosedCaption +import androidx.compose.material.icons.filled.ClosedCaptionDisabled +import androidx.compose.material.icons.filled.ClosedCaptionOff +import androidx.compose.material.icons.filled.ControlCamera +import androidx.compose.material.icons.filled.Equalizer +import androidx.compose.material.icons.filled.Explicit +import androidx.compose.material.icons.filled.FastForward +import androidx.compose.material.icons.filled.FastRewind +import androidx.compose.material.icons.filled.FiberDvr +import androidx.compose.material.icons.filled.FiberManualRecord +import androidx.compose.material.icons.filled.FiberNew +import androidx.compose.material.icons.filled.FiberPin +import androidx.compose.material.icons.filled.FiberSmartRecord +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Forward30 +import androidx.compose.material.icons.filled.Forward5 +import androidx.compose.material.icons.filled.Games +import androidx.compose.material.icons.filled.Hd +import androidx.compose.material.icons.filled.Hearing +import androidx.compose.material.icons.filled.HearingDisabled +import androidx.compose.material.icons.filled.HighQuality +import androidx.compose.material.icons.filled.InterpreterMode +import androidx.compose.material.icons.filled.LibraryAdd +import androidx.compose.material.icons.filled.LibraryAddCheck +import androidx.compose.material.icons.filled.LibraryBooks +import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material.icons.filled.Loop +import androidx.compose.material.icons.filled.Lyrics +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicExternalOff +import androidx.compose.material.icons.filled.MicExternalOn +import androidx.compose.material.icons.filled.MicNone +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material.icons.filled.MissedVideoCall +import androidx.compose.material.icons.filled.Movie +import androidx.compose.material.icons.filled.MovieCreation +import androidx.compose.material.icons.filled.MovieFilter +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.MusicOff +import androidx.compose.material.icons.filled.MusicVideo +import androidx.compose.material.icons.filled.NewReleases +import androidx.compose.material.icons.filled.NotInterested +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PauseCircle +import androidx.compose.material.icons.filled.PauseCircleFilled +import androidx.compose.material.icons.filled.PauseCircleOutline +import androidx.compose.material.icons.filled.PausePresentation +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.PlayCircle +import androidx.compose.material.icons.filled.PlayCircleFilled +import androidx.compose.material.icons.filled.PlayCircleOutline +import androidx.compose.material.icons.filled.PlayDisabled +import androidx.compose.material.icons.filled.PlayLesson +import androidx.compose.material.icons.filled.PlaylistAdd +import androidx.compose.material.icons.filled.PlaylistAddCheck +import androidx.compose.material.icons.filled.PlaylistAddCheckCircle +import androidx.compose.material.icons.filled.PlaylistAddCircle +import androidx.compose.material.icons.filled.PlaylistPlay +import androidx.compose.material.icons.filled.PlaylistRemove +import androidx.compose.material.icons.filled.Queue +import androidx.compose.material.icons.filled.QueuePlayNext +import androidx.compose.material.icons.filled.Radio +import androidx.compose.material.icons.filled.RecentActors +import androidx.compose.material.icons.filled.RemoveFromQueue +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.RepeatOn +import androidx.compose.material.icons.filled.RepeatOne +import androidx.compose.material.icons.filled.RepeatOneOn +import androidx.compose.material.icons.filled.Replay +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material.icons.filled.Replay30 +import androidx.compose.material.icons.filled.Replay5 +import androidx.compose.material.icons.filled.ReplayCircleFilled +import androidx.compose.material.icons.filled.Sd +import androidx.compose.material.icons.filled.SdCard +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material.icons.filled.ShuffleOn +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +import androidx.compose.material.icons.filled.SlowMotionVideo +import androidx.compose.material.icons.filled.Snooze +import androidx.compose.material.icons.filled.SortByAlpha +import androidx.compose.material.icons.filled.Speed +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.StopCircle +import androidx.compose.material.icons.filled.StopScreenShare +import androidx.compose.material.icons.filled.Subscriptions +import androidx.compose.material.icons.filled.Subtitles +import androidx.compose.material.icons.filled.SurroundSound +import androidx.compose.material.icons.filled.VideoCall +import androidx.compose.material.icons.filled.VideoCameraBack +import androidx.compose.material.icons.filled.VideoCameraFront +import androidx.compose.material.icons.filled.VideoFile +import androidx.compose.material.icons.filled.VideoLabel +import androidx.compose.material.icons.filled.VideoLibrary +import androidx.compose.material.icons.filled.VideoSettings +import androidx.compose.material.icons.filled.VideoStable +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.VideocamOff +import androidx.compose.material.icons.filled.VideogameAsset +import androidx.compose.material.icons.filled.VideogameAssetOff +import androidx.compose.material.icons.filled.Web +import androidx.compose.material.icons.filled.WebAsset +import androidx.compose.material.icons.filled.WebAssetOff +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * AV (Audio/Video) category icons - Media controls and playback + * Based on Google's Material Design Icons taxonomy + */ +object AVIcons { + val icons = + listOf( + // ProfileIcon("10k", Icons.Filled.TenK, "10K"), // Not available in compose-material-icons-extended + // ProfileIcon("10mp", Icons.Filled.TenMp, "10MP"), + // ProfileIcon("11mp", Icons.Filled.ElevenMp, "11MP"), + // ProfileIcon("12mp", Icons.Filled.TwelveMp, "12MP"), + // ProfileIcon("13mp", Icons.Filled.ThirteenMp, "13MP"), + // ProfileIcon("14mp", Icons.Filled.FourteenMp, "14MP"), + // ProfileIcon("15mp", Icons.Filled.FifteenMp, "15MP"), + // ProfileIcon("16mp", Icons.Filled.SixteenMp, "16MP"), + // ProfileIcon("17mp", Icons.Filled.SeventeenMp, "17MP"), + // ProfileIcon("18mp", Icons.Filled.EighteenMp, "18MP"), + // ProfileIcon("19mp", Icons.Filled.NineteenMp, "19MP"), + // ProfileIcon("1k", Icons.Filled.OneK, "1K"), + // ProfileIcon("1k_plus", Icons.Filled.OneKPlus, "1K+"), + // ProfileIcon("20mp", Icons.Filled.TwentyMp, "20MP"), + // ProfileIcon("21mp", Icons.Filled.TwentyOneMp, "21MP"), + // ProfileIcon("22mp", Icons.Filled.TwentyTwoMp, "22MP"), + // ProfileIcon("23mp", Icons.Filled.TwentyThreeMp, "23MP"), + // ProfileIcon("24mp", Icons.Filled.TwentyFourMp, "24MP"), + // ProfileIcon("2k", Icons.Filled.TwoK, "2K"), + // ProfileIcon("2k_plus", Icons.Filled.TwoKPlus, "2K+"), + // ProfileIcon("2mp", Icons.Filled.TwoMp, "2MP"), + // ProfileIcon("3k", Icons.Filled.ThreeK, "3K"), + // ProfileIcon("3k_plus", Icons.Filled.ThreeKPlus, "3K+"), + // ProfileIcon("3mp", Icons.Filled.ThreeMp, "3MP"), + // ProfileIcon("4k", Icons.Filled.FourK, "4K"), // Not available + // ProfileIcon("4k_plus", Icons.Filled.FourKPlus, "4K+"), // Not available + // ProfileIcon("4mp", Icons.Filled.FourMp, "4MP"), + // ProfileIcon("5g", Icons.Filled.FiveG, "5G"), + // ProfileIcon("5k", Icons.Filled.FiveK, "5K"), + // ProfileIcon("5k_plus", Icons.Filled.FiveKPlus, "5K+"), + // ProfileIcon("5mp", Icons.Filled.FiveMp, "5MP"), + // ProfileIcon("6k", Icons.Filled.SixK, "6K"), + // ProfileIcon("6k_plus", Icons.Filled.SixKPlus, "6K+"), + // ProfileIcon("6mp", Icons.Filled.SixMp, "6MP"), + // ProfileIcon("7k", Icons.Filled.SevenK, "7K"), + // ProfileIcon("7k_plus", Icons.Filled.SevenKPlus, "7K+"), + // ProfileIcon("7mp", Icons.Filled.SevenMp, "7MP"), + // ProfileIcon("8k", Icons.Filled.EightK, "8K"), + // ProfileIcon("8k_plus", Icons.Filled.EightKPlus, "8K+"), + // ProfileIcon("8mp", Icons.Filled.EightMp, "8MP"), + // ProfileIcon("9k", Icons.Filled.NineK, "9K"), + // ProfileIcon("9k_plus", Icons.Filled.NineKPlus, "9K+"), + // ProfileIcon("9mp", Icons.Filled.NineMp, "9MP"), + ProfileIcon("add_to_queue", Icons.Filled.AddToQueue, "Add to Queue"), + ProfileIcon("airplay", Icons.Filled.Airplay, "Airplay"), + ProfileIcon("album", Icons.Filled.Album, "Album"), + ProfileIcon("art_track", Icons.Filled.ArtTrack, "Art Track"), + ProfileIcon("audio_file", Icons.Filled.AudioFile, "Audio File"), + ProfileIcon("av_timer", Icons.Filled.AvTimer, "AV Timer"), + ProfileIcon("branding_watermark", Icons.Filled.BrandingWatermark, "Watermark"), + ProfileIcon("call_to_action", Icons.Filled.CallToAction, "Call to Action"), + ProfileIcon("closed_caption", Icons.Filled.ClosedCaption, "Closed Caption"), + ProfileIcon("closed_caption_disabled", Icons.Filled.ClosedCaptionDisabled, "CC Disabled"), + ProfileIcon("closed_caption_off", Icons.Filled.ClosedCaptionOff, "CC Off"), + ProfileIcon("control_camera", Icons.Filled.ControlCamera, "Control Camera"), + ProfileIcon("equalizer", Icons.Filled.Equalizer, "Equalizer"), + ProfileIcon("explicit", Icons.Filled.Explicit, "Explicit"), + ProfileIcon("fast_forward", Icons.Filled.FastForward, "Fast Forward"), + ProfileIcon("fast_rewind", Icons.Filled.FastRewind, "Fast Rewind"), + ProfileIcon( + "featured_play_list", + Icons.AutoMirrored.Filled.FeaturedPlayList, + "Featured Playlist", + ), + ProfileIcon("featured_video", Icons.AutoMirrored.Filled.FeaturedVideo, "Featured Video"), + ProfileIcon("fiber_dvr", Icons.Filled.FiberDvr, "DVR"), + ProfileIcon("fiber_manual_record", Icons.Filled.FiberManualRecord, "Record"), + ProfileIcon("fiber_new", Icons.Filled.FiberNew, "New"), + ProfileIcon("fiber_pin", Icons.Filled.FiberPin, "Pin"), + ProfileIcon("fiber_smart_record", Icons.Filled.FiberSmartRecord, "Smart Record"), + ProfileIcon("forward_10", Icons.Filled.Forward10, "Forward 10"), + ProfileIcon("forward_30", Icons.Filled.Forward30, "Forward 30"), + ProfileIcon("forward_5", Icons.Filled.Forward5, "Forward 5"), + ProfileIcon("games", Icons.Filled.Games, "Games"), + ProfileIcon("hd", Icons.Filled.Hd, "HD"), + ProfileIcon("hearing", Icons.Filled.Hearing, "Hearing"), + ProfileIcon("hearing_disabled", Icons.Filled.HearingDisabled, "Hearing Disabled"), + ProfileIcon("high_quality", Icons.Filled.HighQuality, "High Quality"), + ProfileIcon("interpreter_mode", Icons.Filled.InterpreterMode, "Interpreter Mode"), + ProfileIcon("library_add", Icons.Filled.LibraryAdd, "Library Add"), + ProfileIcon("library_add_check", Icons.Filled.LibraryAddCheck, "Library Check"), + ProfileIcon("library_books", Icons.Filled.LibraryBooks, "Library Books"), + ProfileIcon("library_music", Icons.Filled.LibraryMusic, "Library Music"), + ProfileIcon("loop", Icons.Filled.Loop, "Loop"), + ProfileIcon("lyrics", Icons.Filled.Lyrics, "Lyrics"), + ProfileIcon("mic", Icons.Filled.Mic, "Mic"), + ProfileIcon("mic_external_off", Icons.Filled.MicExternalOff, "Mic External Off"), + ProfileIcon("mic_external_on", Icons.Filled.MicExternalOn, "Mic External On"), + ProfileIcon("mic_none", Icons.Filled.MicNone, "Mic None"), + ProfileIcon("mic_off", Icons.Filled.MicOff, "Mic Off"), + ProfileIcon("missed_video_call", Icons.Filled.MissedVideoCall, "Missed Video Call"), + ProfileIcon("movie", Icons.Filled.Movie, "Movie"), + ProfileIcon("movie_creation", Icons.Filled.MovieCreation, "Movie Creation"), + ProfileIcon("movie_filter", Icons.Filled.MovieFilter, "Movie Filter"), + ProfileIcon("music_note", Icons.Filled.MusicNote, "Music Note"), + ProfileIcon("music_off", Icons.Filled.MusicOff, "Music Off"), + ProfileIcon("music_video", Icons.Filled.MusicVideo, "Music Video"), + ProfileIcon("new_releases", Icons.Filled.NewReleases, "New Releases"), + ProfileIcon("not_interested", Icons.Filled.NotInterested, "Not Interested"), + ProfileIcon("note", Icons.AutoMirrored.Filled.Note, "Note"), + ProfileIcon("pause", Icons.Filled.Pause, "Pause"), + ProfileIcon("pause_circle", Icons.Filled.PauseCircle, "Pause Circle"), + ProfileIcon("pause_circle_filled", Icons.Filled.PauseCircleFilled, "Pause Filled"), + ProfileIcon("pause_circle_outline", Icons.Filled.PauseCircleOutline, "Pause Outline"), + ProfileIcon("pause_presentation", Icons.Filled.PausePresentation, "Pause Presentation"), + ProfileIcon("play_arrow", Icons.Filled.PlayArrow, "Play"), + ProfileIcon("play_circle", Icons.Filled.PlayCircle, "Play Circle"), + ProfileIcon("play_circle_filled", Icons.Filled.PlayCircleFilled, "Play Filled"), + ProfileIcon("play_circle_outline", Icons.Filled.PlayCircleOutline, "Play Outline"), + ProfileIcon("play_disabled", Icons.Filled.PlayDisabled, "Play Disabled"), + ProfileIcon("play_lesson", Icons.Filled.PlayLesson, "Play Lesson"), + ProfileIcon("playlist_add", Icons.Filled.PlaylistAdd, "Playlist Add"), + ProfileIcon("playlist_add_check", Icons.Filled.PlaylistAddCheck, "Playlist Check"), + ProfileIcon( + "playlist_add_check_circle", + Icons.Filled.PlaylistAddCheckCircle, + "Playlist Circle", + ), + ProfileIcon("playlist_add_circle", Icons.Filled.PlaylistAddCircle, "Add Circle"), + ProfileIcon("playlist_play", Icons.Filled.PlaylistPlay, "Playlist Play"), + ProfileIcon("playlist_remove", Icons.Filled.PlaylistRemove, "Playlist Remove"), + ProfileIcon("queue", Icons.Filled.Queue, "Queue"), + ProfileIcon("queue_music", Icons.AutoMirrored.Filled.QueueMusic, "Queue Music"), + ProfileIcon("queue_play_next", Icons.Filled.QueuePlayNext, "Play Next"), + ProfileIcon("radio", Icons.Filled.Radio, "Radio"), + ProfileIcon("recent_actors", Icons.Filled.RecentActors, "Recent Actors"), + ProfileIcon("remove_from_queue", Icons.Filled.RemoveFromQueue, "Remove Queue"), + ProfileIcon("repeat", Icons.Filled.Repeat, "Repeat"), + ProfileIcon("repeat_on", Icons.Filled.RepeatOn, "Repeat On"), + ProfileIcon("repeat_one", Icons.Filled.RepeatOne, "Repeat One"), + ProfileIcon("repeat_one_on", Icons.Filled.RepeatOneOn, "Repeat One On"), + ProfileIcon("replay", Icons.Filled.Replay, "Replay"), + ProfileIcon("replay_10", Icons.Filled.Replay10, "Replay 10"), + ProfileIcon("replay_30", Icons.Filled.Replay30, "Replay 30"), + ProfileIcon("replay_5", Icons.Filled.Replay5, "Replay 5"), + ProfileIcon("replay_circle_filled", Icons.Filled.ReplayCircleFilled, "Replay Circle"), + ProfileIcon("sd", Icons.Filled.Sd, "SD"), + ProfileIcon("sd_card", Icons.Filled.SdCard, "SD Card"), + ProfileIcon("shuffle", Icons.Filled.Shuffle, "Shuffle"), + ProfileIcon("shuffle_on", Icons.Filled.ShuffleOn, "Shuffle On"), + ProfileIcon("skip_next", Icons.Filled.SkipNext, "Skip Next"), + ProfileIcon("skip_previous", Icons.Filled.SkipPrevious, "Skip Previous"), + ProfileIcon("slow_motion_video", Icons.Filled.SlowMotionVideo, "Slow Motion"), + ProfileIcon("snooze", Icons.Filled.Snooze, "Snooze"), + ProfileIcon("sort_by_alpha", Icons.Filled.SortByAlpha, "Sort Alpha"), + ProfileIcon("speed", Icons.Filled.Speed, "Speed"), + ProfileIcon("stop", Icons.Filled.Stop, "Stop"), + ProfileIcon("stop_circle", Icons.Filled.StopCircle, "Stop Circle"), + ProfileIcon("stop_screen_share", Icons.Filled.StopScreenShare, "Stop Share"), + ProfileIcon("subscriptions", Icons.Filled.Subscriptions, "Subscriptions"), + ProfileIcon("subtitles", Icons.Filled.Subtitles, "Subtitles"), + ProfileIcon("surround_sound", Icons.Filled.SurroundSound, "Surround Sound"), + ProfileIcon("video_call", Icons.Filled.VideoCall, "Video Call"), + ProfileIcon("video_camera_back", Icons.Filled.VideoCameraBack, "Camera Back"), + ProfileIcon("video_camera_front", Icons.Filled.VideoCameraFront, "Camera Front"), + // ProfileIcon("video_collection", Icons.Filled.VideoCollection, "Video Collection"), + ProfileIcon("video_file", Icons.Filled.VideoFile, "Video File"), + ProfileIcon("video_label", Icons.Filled.VideoLabel, "Video Label"), + ProfileIcon("video_library", Icons.Filled.VideoLibrary, "Video Library"), + ProfileIcon("video_settings", Icons.Filled.VideoSettings, "Video Settings"), + ProfileIcon("video_stable", Icons.Filled.VideoStable, "Video Stable"), + ProfileIcon("videocam", Icons.Filled.Videocam, "Videocam"), + ProfileIcon("videocam_off", Icons.Filled.VideocamOff, "Videocam Off"), + ProfileIcon("videogame_asset", Icons.Filled.VideogameAsset, "Videogame"), + ProfileIcon("videogame_asset_off", Icons.Filled.VideogameAssetOff, "Videogame Off"), + ProfileIcon("volume_down", Icons.AutoMirrored.Filled.VolumeDown, "Volume Down"), + ProfileIcon("volume_mute", Icons.AutoMirrored.Filled.VolumeMute, "Mute"), + ProfileIcon("volume_off", Icons.AutoMirrored.Filled.VolumeOff, "Volume Off"), + ProfileIcon("volume_up", Icons.AutoMirrored.Filled.VolumeUp, "Volume Up"), + ProfileIcon("web", Icons.Filled.Web, "Web"), + ProfileIcon("web_asset", Icons.Filled.WebAsset, "Web Asset"), + ProfileIcon("web_asset_off", Icons.Filled.WebAssetOff, "Web Asset Off"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt new file mode 100644 index 0000000000..7512b0dc95 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ActionIcons.kt @@ -0,0 +1,983 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Announcement +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.automirrored.filled.AssignmentReturn +import androidx.compose.material.icons.automirrored.filled.ExitToApp +import androidx.compose.material.icons.automirrored.filled.FactCheck +import androidx.compose.material.icons.automirrored.filled.Grading +import androidx.compose.material.icons.automirrored.filled.Help +import androidx.compose.material.icons.automirrored.filled.HelpCenter +import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.automirrored.filled.Input +import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.automirrored.filled.LabelImportant +import androidx.compose.material.icons.automirrored.filled.LabelOff +import androidx.compose.material.icons.automirrored.filled.Launch +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.filled.ListAlt +import androidx.compose.material.icons.automirrored.filled.Login +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.automirrored.filled.NextPlan +import androidx.compose.material.icons.automirrored.filled.NoteAdd +import androidx.compose.material.icons.automirrored.filled.ReceiptLong +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.filled.Subject +import androidx.compose.material.icons.automirrored.filled.Toc +import androidx.compose.material.icons.automirrored.filled.TrendingDown +import androidx.compose.material.icons.automirrored.filled.TrendingFlat +import androidx.compose.material.icons.automirrored.filled.TrendingUp +import androidx.compose.material.icons.automirrored.filled.ViewList +import androidx.compose.material.icons.automirrored.filled.ViewQuilt +import androidx.compose.material.icons.automirrored.filled.ViewSidebar +import androidx.compose.material.icons.filled.Accessibility +import androidx.compose.material.icons.filled.AccessibilityNew +import androidx.compose.material.icons.filled.Accessible +import androidx.compose.material.icons.filled.AccessibleForward +import androidx.compose.material.icons.filled.AccountBalance +import androidx.compose.material.icons.filled.AccountBalanceWallet +import androidx.compose.material.icons.filled.AccountBox +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.AddShoppingCart +import androidx.compose.material.icons.filled.AddTask +import androidx.compose.material.icons.filled.AddToDrive +import androidx.compose.material.icons.filled.Addchart +import androidx.compose.material.icons.filled.AdminPanelSettings +import androidx.compose.material.icons.filled.AdsClick +import androidx.compose.material.icons.filled.Alarm +import androidx.compose.material.icons.filled.AlarmAdd +import androidx.compose.material.icons.filled.AlarmOff +import androidx.compose.material.icons.filled.AlarmOn +import androidx.compose.material.icons.filled.AllInbox +import androidx.compose.material.icons.filled.AllOut +import androidx.compose.material.icons.filled.Analytics +import androidx.compose.material.icons.filled.Anchor +import androidx.compose.material.icons.filled.Android +import androidx.compose.material.icons.filled.Api +import androidx.compose.material.icons.filled.AppBlocking +import androidx.compose.material.icons.filled.AppRegistration +import androidx.compose.material.icons.filled.AppSettingsAlt +import androidx.compose.material.icons.filled.AppShortcut +import androidx.compose.material.icons.filled.Approval +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.AppsOutage +import androidx.compose.material.icons.filled.ArrowCircleDown +import androidx.compose.material.icons.filled.ArrowCircleLeft +import androidx.compose.material.icons.filled.ArrowCircleRight +import androidx.compose.material.icons.filled.ArrowCircleUp +import androidx.compose.material.icons.filled.ArrowOutward +import androidx.compose.material.icons.filled.AspectRatio +import androidx.compose.material.icons.filled.Assessment +import androidx.compose.material.icons.filled.Assignment +import androidx.compose.material.icons.filled.AssignmentInd +import androidx.compose.material.icons.filled.AssignmentLate +import androidx.compose.material.icons.filled.AssignmentReturned +import androidx.compose.material.icons.filled.AssignmentTurnedIn +import androidx.compose.material.icons.filled.AssuredWorkload +import androidx.compose.material.icons.filled.Attachment +import androidx.compose.material.icons.filled.Autorenew +import androidx.compose.material.icons.filled.Backup +import androidx.compose.material.icons.filled.BackupTable +import androidx.compose.material.icons.filled.Balance +import androidx.compose.material.icons.filled.BatchPrediction +import androidx.compose.material.icons.filled.Book +import androidx.compose.material.icons.filled.BookOnline +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.BookmarkAdd +import androidx.compose.material.icons.filled.BookmarkAdded +import androidx.compose.material.icons.filled.BookmarkBorder +import androidx.compose.material.icons.filled.BookmarkRemove +import androidx.compose.material.icons.filled.Bookmarks +import androidx.compose.material.icons.filled.BugReport +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.BuildCircle +import androidx.compose.material.icons.filled.Cached +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material.icons.filled.CalendarViewDay +import androidx.compose.material.icons.filled.CalendarViewMonth +import androidx.compose.material.icons.filled.CalendarViewWeek +import androidx.compose.material.icons.filled.CameraEnhance +import androidx.compose.material.icons.filled.CancelScheduleSend +import androidx.compose.material.icons.filled.CardGiftcard +import androidx.compose.material.icons.filled.CardMembership +import androidx.compose.material.icons.filled.CardTravel +import androidx.compose.material.icons.filled.ChangeCircle +import androidx.compose.material.icons.filled.ChangeHistory +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.material.icons.filled.ChromeReaderMode +import androidx.compose.material.icons.filled.CircleNotifications +import androidx.compose.material.icons.filled.Class +import androidx.compose.material.icons.filled.CloseFullscreen +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.CodeOff +import androidx.compose.material.icons.filled.CommentBank +import androidx.compose.material.icons.filled.Commute +import androidx.compose.material.icons.filled.CompareArrows +import androidx.compose.material.icons.filled.Compress +import androidx.compose.material.icons.filled.ContactPage +import androidx.compose.material.icons.filled.ContactSupport +import androidx.compose.material.icons.filled.Contactless +import androidx.compose.material.icons.filled.Copyright +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.CreditCardOff +import androidx.compose.material.icons.filled.CreditScore +import androidx.compose.material.icons.filled.Css +import androidx.compose.material.icons.filled.CurrencyExchange +import androidx.compose.material.icons.filled.Dangerous +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.DashboardCustomize +import androidx.compose.material.icons.filled.DataExploration +import androidx.compose.material.icons.filled.DataThresholding +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.DensityLarge +import androidx.compose.material.icons.filled.DensityMedium +import androidx.compose.material.icons.filled.DensitySmall +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.DisabledByDefault +import androidx.compose.material.icons.filled.DisabledVisible +import androidx.compose.material.icons.filled.DisplaySettings +import androidx.compose.material.icons.filled.Dns +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.DoneOutline +import androidx.compose.material.icons.filled.DonutLarge +import androidx.compose.material.icons.filled.DonutSmall +import androidx.compose.material.icons.filled.DragIndicator +import androidx.compose.material.icons.filled.DynamicForm +import androidx.compose.material.icons.filled.Eco +import androidx.compose.material.icons.filled.EditCalendar +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.EditOff +import androidx.compose.material.icons.filled.Eject +import androidx.compose.material.icons.filled.Euro +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.EventRepeat +import androidx.compose.material.icons.filled.EventSeat +import androidx.compose.material.icons.filled.Expand +import androidx.compose.material.icons.filled.Explore +import androidx.compose.material.icons.filled.ExploreOff +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.ExtensionOff +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.filled.Fax +import androidx.compose.material.icons.filled.Feedback +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileDownloadDone +import androidx.compose.material.icons.filled.FileDownloadOff +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.FilePresent +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material.icons.filled.FilterAltOff +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.FilterListOff +import androidx.compose.material.icons.filled.FindInPage +import androidx.compose.material.icons.filled.FindReplace +import androidx.compose.material.icons.filled.Fingerprint +import androidx.compose.material.icons.filled.FitScreen +import androidx.compose.material.icons.filled.Flaky +import androidx.compose.material.icons.filled.FlightLand +import androidx.compose.material.icons.filled.FlightTakeoff +import androidx.compose.material.icons.filled.FlipToBack +import androidx.compose.material.icons.filled.FlipToFront +import androidx.compose.material.icons.filled.FlutterDash +import androidx.compose.material.icons.filled.FreeCancellation +import androidx.compose.material.icons.filled.GTranslate +import androidx.compose.material.icons.filled.Gavel +import androidx.compose.material.icons.filled.GeneratingTokens +import androidx.compose.material.icons.filled.GetApp +import androidx.compose.material.icons.filled.Gif +import androidx.compose.material.icons.filled.GifBox +import androidx.compose.material.icons.filled.Grade +import androidx.compose.material.icons.filled.GroupWork +import androidx.compose.material.icons.filled.HideSource +import androidx.compose.material.icons.filled.HighlightAlt +import androidx.compose.material.icons.filled.HighlightOff +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.HistoryToggleOff +import androidx.compose.material.icons.filled.Hls +import androidx.compose.material.icons.filled.HlsOff +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.HorizontalSplit +import androidx.compose.material.icons.filled.HourglassDisabled +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.HourglassFull +import androidx.compose.material.icons.filled.Html +import androidx.compose.material.icons.filled.Http +import androidx.compose.material.icons.filled.Https +import androidx.compose.material.icons.filled.ImportantDevices +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.InstallDesktop +import androidx.compose.material.icons.filled.InstallMobile +import androidx.compose.material.icons.filled.IntegrationInstructions +import androidx.compose.material.icons.filled.InvertColors +import androidx.compose.material.icons.filled.Javascript +import androidx.compose.material.icons.filled.JoinFull +import androidx.compose.material.icons.filled.JoinInner +import androidx.compose.material.icons.filled.JoinLeft +import androidx.compose.material.icons.filled.JoinRight +import androidx.compose.material.icons.filled.Language +import androidx.compose.material.icons.filled.Leaderboard +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.LightbulbCircle +import androidx.compose.material.icons.filled.LineStyle +import androidx.compose.material.icons.filled.LineWeight +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockClock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.filled.LockPerson +import androidx.compose.material.icons.filled.LockReset +import androidx.compose.material.icons.filled.Loyalty +import androidx.compose.material.icons.filled.ManageAccounts +import androidx.compose.material.icons.filled.ManageHistory +import androidx.compose.material.icons.filled.ManageSearch +import androidx.compose.material.icons.filled.MarkAsUnread +import androidx.compose.material.icons.filled.MarkunreadMailbox +import androidx.compose.material.icons.filled.Maximize +import androidx.compose.material.icons.filled.Mediation +import androidx.compose.material.icons.filled.Minimize +import androidx.compose.material.icons.filled.ModelTraining +import androidx.compose.material.icons.filled.Nightlight +import androidx.compose.material.icons.filled.NightlightRound +import androidx.compose.material.icons.filled.NoAccounts +import androidx.compose.material.icons.filled.NotStarted +import androidx.compose.material.icons.filled.OfflineBolt +import androidx.compose.material.icons.filled.OfflinePin +import androidx.compose.material.icons.filled.OnlinePrediction +import androidx.compose.material.icons.filled.Opacity +import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.OpenInFull +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.OpenInNewOff +import androidx.compose.material.icons.filled.OpenWith +import androidx.compose.material.icons.filled.Outbond +import androidx.compose.material.icons.filled.Outlet +import androidx.compose.material.icons.filled.Output +import androidx.compose.material.icons.filled.Pageview +import androidx.compose.material.icons.filled.Paid +import androidx.compose.material.icons.filled.PanTool +import androidx.compose.material.icons.filled.PanToolAlt +import androidx.compose.material.icons.filled.Payment +import androidx.compose.material.icons.filled.Pending +import androidx.compose.material.icons.filled.PendingActions +import androidx.compose.material.icons.filled.Percent +import androidx.compose.material.icons.filled.PermCameraMic +import androidx.compose.material.icons.filled.PermContactCalendar +import androidx.compose.material.icons.filled.PermDataSetting +import androidx.compose.material.icons.filled.PermDeviceInformation +import androidx.compose.material.icons.filled.PermIdentity +import androidx.compose.material.icons.filled.PermMedia +import androidx.compose.material.icons.filled.PermPhoneMsg +import androidx.compose.material.icons.filled.PermScanWifi +import androidx.compose.material.icons.filled.Pets +import androidx.compose.material.icons.filled.Php +import androidx.compose.material.icons.filled.PictureInPicture +import androidx.compose.material.icons.filled.PictureInPictureAlt +import androidx.compose.material.icons.filled.PinEnd +import androidx.compose.material.icons.filled.PinInvoke +import androidx.compose.material.icons.filled.Plagiarism +import androidx.compose.material.icons.filled.PlayForWork +import androidx.compose.material.icons.filled.Polymer +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.material.icons.filled.PregnantWoman +import androidx.compose.material.icons.filled.Preview +import androidx.compose.material.icons.filled.Print +import androidx.compose.material.icons.filled.PrintDisabled +import androidx.compose.material.icons.filled.PrivacyTip +import androidx.compose.material.icons.filled.ProductionQuantityLimits +import androidx.compose.material.icons.filled.PublishedWithChanges +import androidx.compose.material.icons.filled.QueryBuilder +import androidx.compose.material.icons.filled.QuestionAnswer +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.filled.Quickreply +import androidx.compose.material.icons.filled.Receipt +import androidx.compose.material.icons.filled.RecordVoiceOver +import androidx.compose.material.icons.filled.Redeem +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.RemoveDone +import androidx.compose.material.icons.filled.RemoveShoppingCart +import androidx.compose.material.icons.filled.Reorder +import androidx.compose.material.icons.filled.Repartition +import androidx.compose.material.icons.filled.ReportProblem +import androidx.compose.material.icons.filled.RequestPage +import androidx.compose.material.icons.filled.RequestQuote +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.filled.RestoreFromTrash +import androidx.compose.material.icons.filled.RestorePage +import androidx.compose.material.icons.filled.Rocket +import androidx.compose.material.icons.filled.RocketLaunch +import androidx.compose.material.icons.filled.Room +import androidx.compose.material.icons.filled.RoundedCorner +import androidx.compose.material.icons.filled.Rowing +import androidx.compose.material.icons.filled.Rule +import androidx.compose.material.icons.filled.SatelliteAlt +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material.icons.filled.SaveAs +import androidx.compose.material.icons.filled.SavedSearch +import androidx.compose.material.icons.filled.Savings +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.ScheduleSend +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material.icons.filled.Segment +import androidx.compose.material.icons.filled.SendAndArchive +import androidx.compose.material.icons.filled.Sensors +import androidx.compose.material.icons.filled.SensorsOff +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.SettingsAccessibility +import androidx.compose.material.icons.filled.SettingsApplications +import androidx.compose.material.icons.filled.SettingsBackupRestore +import androidx.compose.material.icons.filled.SettingsBluetooth +import androidx.compose.material.icons.filled.SettingsBrightness +import androidx.compose.material.icons.filled.SettingsCell +import androidx.compose.material.icons.filled.SettingsEthernet +import androidx.compose.material.icons.filled.SettingsInputAntenna +import androidx.compose.material.icons.filled.SettingsInputComponent +import androidx.compose.material.icons.filled.SettingsInputComposite +import androidx.compose.material.icons.filled.SettingsInputHdmi +import androidx.compose.material.icons.filled.SettingsInputSvideo +import androidx.compose.material.icons.filled.SettingsOverscan +import androidx.compose.material.icons.filled.SettingsPhone +import androidx.compose.material.icons.filled.SettingsPower +import androidx.compose.material.icons.filled.SettingsRemote +import androidx.compose.material.icons.filled.SettingsVoice +import androidx.compose.material.icons.filled.Shop +import androidx.compose.material.icons.filled.Shop2 +import androidx.compose.material.icons.filled.ShopTwo +import androidx.compose.material.icons.filled.ShoppingBag +import androidx.compose.material.icons.filled.ShoppingBasket +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material.icons.filled.ShoppingCartCheckout +import androidx.compose.material.icons.filled.SmartButton +import androidx.compose.material.icons.filled.Source +import androidx.compose.material.icons.filled.SpaceDashboard +import androidx.compose.material.icons.filled.SpatialAudio +import androidx.compose.material.icons.filled.SpatialAudioOff +import androidx.compose.material.icons.filled.SpatialTracking +import androidx.compose.material.icons.filled.SpeakerNotes +import androidx.compose.material.icons.filled.SpeakerNotesOff +import androidx.compose.material.icons.filled.Spellcheck +import androidx.compose.material.icons.filled.StarRate +import androidx.compose.material.icons.filled.Stars +import androidx.compose.material.icons.filled.StickyNote2 +import androidx.compose.material.icons.filled.Store +import androidx.compose.material.icons.filled.SubtitlesOff +import androidx.compose.material.icons.filled.SupervisedUserCircle +import androidx.compose.material.icons.filled.SupervisorAccount +import androidx.compose.material.icons.filled.Support +import androidx.compose.material.icons.filled.SwapHoriz +import androidx.compose.material.icons.filled.SwapHorizontalCircle +import androidx.compose.material.icons.filled.SwapVert +import androidx.compose.material.icons.filled.SwapVerticalCircle +import androidx.compose.material.icons.filled.Swipe +import androidx.compose.material.icons.filled.SwipeDown +import androidx.compose.material.icons.filled.SwipeDownAlt +import androidx.compose.material.icons.filled.SwipeLeft +import androidx.compose.material.icons.filled.SwipeLeftAlt +import androidx.compose.material.icons.filled.SwipeRight +import androidx.compose.material.icons.filled.SwipeRightAlt +import androidx.compose.material.icons.filled.SwipeUp +import androidx.compose.material.icons.filled.SwipeUpAlt +import androidx.compose.material.icons.filled.SwipeVertical +import androidx.compose.material.icons.filled.SwitchAccessShortcut +import androidx.compose.material.icons.filled.SwitchAccessShortcutAdd +import androidx.compose.material.icons.filled.SyncAlt +import androidx.compose.material.icons.filled.SystemUpdateAlt +import androidx.compose.material.icons.filled.Tab +import androidx.compose.material.icons.filled.TabUnselected +import androidx.compose.material.icons.filled.TableView +import androidx.compose.material.icons.filled.TagFaces +import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.TextRotateUp +import androidx.compose.material.icons.filled.TextRotateVertical +import androidx.compose.material.icons.filled.TextRotationAngledown +import androidx.compose.material.icons.filled.TextRotationAngleup +import androidx.compose.material.icons.filled.TextRotationDown +import androidx.compose.material.icons.filled.TextRotationNone +import androidx.compose.material.icons.filled.Theaters +import androidx.compose.material.icons.filled.ThumbDown +import androidx.compose.material.icons.filled.ThumbDownOffAlt +import androidx.compose.material.icons.filled.ThumbUp +import androidx.compose.material.icons.filled.ThumbUpOffAlt +import androidx.compose.material.icons.filled.ThumbsUpDown +import androidx.compose.material.icons.filled.Timeline +import androidx.compose.material.icons.filled.TipsAndUpdates +import androidx.compose.material.icons.filled.Today +import androidx.compose.material.icons.filled.Token +import androidx.compose.material.icons.filled.Toll +import androidx.compose.material.icons.filled.TouchApp +import androidx.compose.material.icons.filled.Tour +import androidx.compose.material.icons.filled.TrackChanges +import androidx.compose.material.icons.filled.Transcribe +import androidx.compose.material.icons.filled.Translate +import androidx.compose.material.icons.filled.Troubleshoot +import androidx.compose.material.icons.filled.TurnedIn +import androidx.compose.material.icons.filled.TurnedInNot +import androidx.compose.material.icons.filled.UnfoldLessDouble +import androidx.compose.material.icons.filled.UnfoldMoreDouble +import androidx.compose.material.icons.filled.Unpublished +import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.filled.UpdateDisabled +import androidx.compose.material.icons.filled.Upgrade +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material.icons.filled.VerticalSplit +import androidx.compose.material.icons.filled.ViewAgenda +import androidx.compose.material.icons.filled.ViewArray +import androidx.compose.material.icons.filled.ViewCarousel +import androidx.compose.material.icons.filled.ViewColumn +import androidx.compose.material.icons.filled.ViewComfy +import androidx.compose.material.icons.filled.ViewComfyAlt +import androidx.compose.material.icons.filled.ViewCompact +import androidx.compose.material.icons.filled.ViewCompactAlt +import androidx.compose.material.icons.filled.ViewCozy +import androidx.compose.material.icons.filled.ViewDay +import androidx.compose.material.icons.filled.ViewHeadline +import androidx.compose.material.icons.filled.ViewInAr +import androidx.compose.material.icons.filled.ViewKanban +import androidx.compose.material.icons.filled.ViewModule +import androidx.compose.material.icons.filled.ViewStream +import androidx.compose.material.icons.filled.ViewTimeline +import androidx.compose.material.icons.filled.ViewWeek +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.VoiceOverOff +import androidx.compose.material.icons.filled.WatchLater +import androidx.compose.material.icons.filled.Webhook +import androidx.compose.material.icons.filled.WidthFull +import androidx.compose.material.icons.filled.WidthNormal +import androidx.compose.material.icons.filled.WidthWide +import androidx.compose.material.icons.filled.WifiProtectedSetup +import androidx.compose.material.icons.filled.Work +import androidx.compose.material.icons.filled.WorkHistory +import androidx.compose.material.icons.filled.WorkOff +import androidx.compose.material.icons.filled.WorkOutline +import androidx.compose.material.icons.filled.Wysiwyg +import androidx.compose.material.icons.filled.ZoomIn +import androidx.compose.material.icons.filled.ZoomOut +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Action category icons - User actions and common UI operations + * Based on Google's Material Design Icons taxonomy + */ +object ActionIcons { + val icons = + listOf( + // ProfileIcon("3d_rotation", Icons.Filled.ThreeDRotation, "3D Rotation"), + ProfileIcon("accessibility", Icons.Filled.Accessibility, "Accessibility"), + ProfileIcon("accessibility_new", Icons.Filled.AccessibilityNew, "Accessibility New"), + ProfileIcon("accessible", Icons.Filled.Accessible, "Accessible"), + ProfileIcon("accessible_forward", Icons.Filled.AccessibleForward, "Accessible Forward"), + ProfileIcon("account_balance", Icons.Filled.AccountBalance, "Account Balance"), + ProfileIcon("account_balance_wallet", Icons.Filled.AccountBalanceWallet, "Wallet"), + ProfileIcon("account_box", Icons.Filled.AccountBox, "Account Box"), + ProfileIcon("account_circle", Icons.Filled.AccountCircle, "Account"), + ProfileIcon("add_shopping_cart", Icons.Filled.AddShoppingCart, "Add Cart"), + ProfileIcon("add_task", Icons.Filled.AddTask, "Add Task"), + ProfileIcon("add_to_drive", Icons.Filled.AddToDrive, "Add to Drive"), + ProfileIcon("addchart", Icons.Filled.Addchart, "Add Chart"), + ProfileIcon("admin_panel_settings", Icons.Filled.AdminPanelSettings, "Admin Panel"), + ProfileIcon("ads_click", Icons.Filled.AdsClick, "Ads Click"), + ProfileIcon("alarm", Icons.Filled.Alarm, "Alarm"), + ProfileIcon("alarm_add", Icons.Filled.AlarmAdd, "Add Alarm"), + ProfileIcon("alarm_off", Icons.Filled.AlarmOff, "Alarm Off"), + ProfileIcon("alarm_on", Icons.Filled.AlarmOn, "Alarm On"), + ProfileIcon("all_inbox", Icons.Filled.AllInbox, "All Inbox"), + ProfileIcon("all_out", Icons.Filled.AllOut, "All Out"), + ProfileIcon("analytics", Icons.Filled.Analytics, "Analytics"), + ProfileIcon("anchor", Icons.Filled.Anchor, "Anchor"), + ProfileIcon("android", Icons.Filled.Android, "Android"), + ProfileIcon("announcement", Icons.AutoMirrored.Filled.Announcement, "Announcement"), + ProfileIcon("api", Icons.Filled.Api, "API"), + ProfileIcon("app_blocking", Icons.Filled.AppBlocking, "App Blocking"), + ProfileIcon("app_registration", Icons.Filled.AppRegistration, "App Registration"), + ProfileIcon("app_settings_alt", Icons.Filled.AppSettingsAlt, "App Settings"), + ProfileIcon("app_shortcut", Icons.Filled.AppShortcut, "App Shortcut"), + ProfileIcon("approval", Icons.Filled.Approval, "Approval"), + ProfileIcon("apps", Icons.Filled.Apps, "Apps"), + ProfileIcon("apps_outage", Icons.Filled.AppsOutage, "Apps Outage"), + ProfileIcon("arrow_circle_down", Icons.Filled.ArrowCircleDown, "Arrow Down"), + ProfileIcon("arrow_circle_left", Icons.Filled.ArrowCircleLeft, "Arrow Left"), + ProfileIcon("arrow_circle_right", Icons.Filled.ArrowCircleRight, "Arrow Right"), + ProfileIcon("arrow_circle_up", Icons.Filled.ArrowCircleUp, "Arrow Up"), + ProfileIcon("arrow_outward", Icons.Filled.ArrowOutward, "Arrow Outward"), + ProfileIcon("article", Icons.AutoMirrored.Filled.Article, "Article"), + ProfileIcon("aspect_ratio", Icons.Filled.AspectRatio, "Aspect Ratio"), + ProfileIcon("assessment", Icons.Filled.Assessment, "Assessment"), + ProfileIcon("assignment", Icons.Filled.Assignment, "Assignment"), + ProfileIcon("assignment_ind", Icons.Filled.AssignmentInd, "Assignment Ind"), + ProfileIcon("assignment_late", Icons.Filled.AssignmentLate, "Assignment Late"), + ProfileIcon( + "assignment_return", + Icons.AutoMirrored.Filled.AssignmentReturn, + "Assignment Return", + ), + ProfileIcon("assignment_returned", Icons.Filled.AssignmentReturned, "Assignment Returned"), + ProfileIcon("assignment_turned_in", Icons.Filled.AssignmentTurnedIn, "Done"), + ProfileIcon("assured_workload", Icons.Filled.AssuredWorkload, "Assured Workload"), + ProfileIcon("attachment", Icons.Filled.Attachment, "Attachment"), + ProfileIcon("autorenew", Icons.Filled.Autorenew, "Auto Renew"), + ProfileIcon("backup", Icons.Filled.Backup, "Backup"), + ProfileIcon("backup_table", Icons.Filled.BackupTable, "Backup Table"), + ProfileIcon("balance", Icons.Filled.Balance, "Balance"), + ProfileIcon("batch_prediction", Icons.Filled.BatchPrediction, "Batch Prediction"), + ProfileIcon("book", Icons.Filled.Book, "Book"), + ProfileIcon("book_online", Icons.Filled.BookOnline, "Book Online"), + ProfileIcon("bookmark", Icons.Filled.Bookmark, "Bookmark"), + ProfileIcon("bookmark_add", Icons.Filled.BookmarkAdd, "Bookmark Add"), + ProfileIcon("bookmark_added", Icons.Filled.BookmarkAdded, "Bookmark Added"), + ProfileIcon("bookmark_border", Icons.Filled.BookmarkBorder, "Bookmark Border"), + ProfileIcon("bookmark_remove", Icons.Filled.BookmarkRemove, "Bookmark Remove"), + ProfileIcon("bookmarks", Icons.Filled.Bookmarks, "Bookmarks"), + ProfileIcon("bug_report", Icons.Filled.BugReport, "Bug Report"), + ProfileIcon("build", Icons.Filled.Build, "Build"), + ProfileIcon("build_circle", Icons.Filled.BuildCircle, "Build Circle"), + ProfileIcon("cached", Icons.Filled.Cached, "Cached"), + ProfileIcon("calendar_month", Icons.Filled.CalendarMonth, "Calendar Month"), + ProfileIcon("calendar_today", Icons.Filled.CalendarToday, "Calendar Today"), + ProfileIcon("calendar_view_day", Icons.Filled.CalendarViewDay, "Calendar Day"), + ProfileIcon("calendar_view_month", Icons.Filled.CalendarViewMonth, "Calendar Month View"), + ProfileIcon("calendar_view_week", Icons.Filled.CalendarViewWeek, "Calendar Week"), + ProfileIcon("camera_enhance", Icons.Filled.CameraEnhance, "Camera Enhance"), + ProfileIcon("cancel_schedule_send", Icons.Filled.CancelScheduleSend, "Cancel Schedule"), + ProfileIcon("card_giftcard", Icons.Filled.CardGiftcard, "Gift Card"), + ProfileIcon("card_membership", Icons.Filled.CardMembership, "Membership"), + ProfileIcon("card_travel", Icons.Filled.CardTravel, "Travel Card"), + ProfileIcon("change_circle", Icons.Filled.ChangeCircle, "Change Circle"), + ProfileIcon("change_history", Icons.Filled.ChangeHistory, "Change History"), + ProfileIcon("check_circle", Icons.Filled.CheckCircle, "Check Circle"), + ProfileIcon( + "check_circle_outline", + Icons.Filled.CheckCircleOutline, + "Check Circle Outline", + ), + ProfileIcon("chrome_reader_mode", Icons.Filled.ChromeReaderMode, "Reader Mode"), + ProfileIcon( + "circle_notifications", + Icons.Filled.CircleNotifications, + "Circle Notifications", + ), + ProfileIcon("class", Icons.Filled.Class, "Class"), + ProfileIcon("close_fullscreen", Icons.Filled.CloseFullscreen, "Close Fullscreen"), + ProfileIcon("code", Icons.Filled.Code, "Code"), + ProfileIcon("code_off", Icons.Filled.CodeOff, "Code Off"), + ProfileIcon("comment_bank", Icons.Filled.CommentBank, "Comment Bank"), + ProfileIcon("commute", Icons.Filled.Commute, "Commute"), + ProfileIcon("compare_arrows", Icons.Filled.CompareArrows, "Compare"), + ProfileIcon("compress", Icons.Filled.Compress, "Compress"), + ProfileIcon("contact_page", Icons.Filled.ContactPage, "Contact Page"), + ProfileIcon("contact_support", Icons.Filled.ContactSupport, "Contact Support"), + ProfileIcon("contactless", Icons.Filled.Contactless, "Contactless"), + ProfileIcon("copyright", Icons.Filled.Copyright, "Copyright"), + ProfileIcon("credit_card", Icons.Filled.CreditCard, "Credit Card"), + ProfileIcon("credit_card_off", Icons.Filled.CreditCardOff, "Credit Card Off"), + ProfileIcon("credit_score", Icons.Filled.CreditScore, "Credit Score"), + ProfileIcon("css", Icons.Filled.Css, "CSS"), + ProfileIcon("currency_exchange", Icons.Filled.CurrencyExchange, "Currency Exchange"), + ProfileIcon("dangerous", Icons.Filled.Dangerous, "Dangerous"), + ProfileIcon("dashboard", Icons.Filled.Dashboard, "Dashboard"), + ProfileIcon("dashboard_customize", Icons.Filled.DashboardCustomize, "Dashboard Customize"), + ProfileIcon("data_exploration", Icons.Filled.DataExploration, "Data Exploration"), + ProfileIcon("data_thresholding", Icons.Filled.DataThresholding, "Data Thresholding"), + ProfileIcon("date_range", Icons.Filled.DateRange, "Date Range"), + ProfileIcon("delete", Icons.Filled.Delete, "Delete"), + ProfileIcon("delete_forever", Icons.Filled.DeleteForever, "Delete Forever"), + ProfileIcon("delete_outline", Icons.Filled.DeleteOutline, "Delete Outline"), + ProfileIcon("delete_sweep", Icons.Filled.DeleteSweep, "Delete Sweep"), + ProfileIcon("density_large", Icons.Filled.DensityLarge, "Density Large"), + ProfileIcon("density_medium", Icons.Filled.DensityMedium, "Density Medium"), + ProfileIcon("density_small", Icons.Filled.DensitySmall, "Density Small"), + ProfileIcon("description", Icons.Filled.Description, "Description"), + ProfileIcon("disabled_by_default", Icons.Filled.DisabledByDefault, "Disabled"), + ProfileIcon("disabled_visible", Icons.Filled.DisabledVisible, "Disabled Visible"), + ProfileIcon("display_settings", Icons.Filled.DisplaySettings, "Display Settings"), + ProfileIcon("dns", Icons.Filled.Dns, "DNS"), + ProfileIcon("done", Icons.Filled.Done, "Done"), + ProfileIcon("done_all", Icons.Filled.DoneAll, "Done All"), + ProfileIcon("done_outline", Icons.Filled.DoneOutline, "Done Outline"), + ProfileIcon("donut_large", Icons.Filled.DonutLarge, "Donut Large"), + ProfileIcon("donut_small", Icons.Filled.DonutSmall, "Donut Small"), + ProfileIcon("drag_indicator", Icons.Filled.DragIndicator, "Drag"), + ProfileIcon("dynamic_form", Icons.Filled.DynamicForm, "Dynamic Form"), + ProfileIcon("eco", Icons.Filled.Eco, "Eco"), + ProfileIcon("edit_calendar", Icons.Filled.EditCalendar, "Edit Calendar"), + ProfileIcon("edit_note", Icons.Filled.EditNote, "Edit Note"), + ProfileIcon("edit_off", Icons.Filled.EditOff, "Edit Off"), + ProfileIcon("eject", Icons.Filled.Eject, "Eject"), + ProfileIcon("euro_symbol", Icons.Filled.Euro, "Euro"), + ProfileIcon("event", Icons.Filled.Event, "Event"), + ProfileIcon("event_repeat", Icons.Filled.EventRepeat, "Event Repeat"), + ProfileIcon("event_seat", Icons.Filled.EventSeat, "Event Seat"), + ProfileIcon("exit_to_app", Icons.AutoMirrored.Filled.ExitToApp, "Exit"), + ProfileIcon("expand", Icons.Filled.Expand, "Expand"), + ProfileIcon("explore", Icons.Filled.Explore, "Explore"), + ProfileIcon("explore_off", Icons.Filled.ExploreOff, "Explore Off"), + ProfileIcon("extension", Icons.Filled.Extension, "Extension"), + ProfileIcon("extension_off", Icons.Filled.ExtensionOff, "Extension Off"), + ProfileIcon("face", Icons.Filled.Face, "Face"), + // ProfileIcon("face_unlock", Icons.Filled.FaceUnlock, "Face Unlock"), + ProfileIcon("fact_check", Icons.AutoMirrored.Filled.FactCheck, "Fact Check"), + ProfileIcon("favorite", Icons.Filled.Favorite, "Favorite"), + ProfileIcon("favorite_border", Icons.Filled.FavoriteBorder, "Favorite Border"), + ProfileIcon("fax", Icons.Filled.Fax, "Fax"), + ProfileIcon("feedback", Icons.Filled.Feedback, "Feedback"), + ProfileIcon("file_download", Icons.Filled.FileDownload, "Download"), + ProfileIcon("file_download_done", Icons.Filled.FileDownloadDone, "Download Done"), + ProfileIcon("file_download_off", Icons.Filled.FileDownloadOff, "Download Off"), + ProfileIcon("file_open", Icons.Filled.FileOpen, "File Open"), + ProfileIcon("file_present", Icons.Filled.FilePresent, "File Present"), + ProfileIcon("file_upload", Icons.Filled.FileUpload, "Upload"), + ProfileIcon("filter_alt", Icons.Filled.FilterAlt, "Filter Alt"), + ProfileIcon("filter_alt_off", Icons.Filled.FilterAltOff, "Filter Alt Off"), + ProfileIcon("filter_list", Icons.Filled.FilterList, "Filter"), + ProfileIcon("filter_list_off", Icons.Filled.FilterListOff, "Filter Off"), + ProfileIcon("find_in_page", Icons.Filled.FindInPage, "Find"), + ProfileIcon("find_replace", Icons.Filled.FindReplace, "Find Replace"), + ProfileIcon("fingerprint", Icons.Filled.Fingerprint, "Fingerprint"), + ProfileIcon("fit_screen", Icons.Filled.FitScreen, "Fit Screen"), + ProfileIcon("flaky", Icons.Filled.Flaky, "Flaky"), + ProfileIcon("flight_land", Icons.Filled.FlightLand, "Landing"), + ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Takeoff"), + ProfileIcon("flip_to_back", Icons.Filled.FlipToBack, "Flip Back"), + ProfileIcon("flip_to_front", Icons.Filled.FlipToFront, "Flip Front"), + ProfileIcon("flutter_dash", Icons.Filled.FlutterDash, "Flutter Dash"), + ProfileIcon("free_cancellation", Icons.Filled.FreeCancellation, "Free Cancellation"), + ProfileIcon("g_translate", Icons.Filled.GTranslate, "Translate"), + ProfileIcon("gavel", Icons.Filled.Gavel, "Gavel"), + ProfileIcon("generating_tokens", Icons.Filled.GeneratingTokens, "Generating Tokens"), + ProfileIcon("get_app", Icons.Filled.GetApp, "Get App"), + ProfileIcon("gif", Icons.Filled.Gif, "GIF"), + ProfileIcon("gif_box", Icons.Filled.GifBox, "GIF Box"), + ProfileIcon("grade", Icons.Filled.Grade, "Grade"), + ProfileIcon("grading", Icons.AutoMirrored.Filled.Grading, "Grading"), + ProfileIcon("group_work", Icons.Filled.GroupWork, "Group Work"), + ProfileIcon("help", Icons.AutoMirrored.Filled.Help, "Help"), + ProfileIcon("help_center", Icons.AutoMirrored.Filled.HelpCenter, "Help Center"), + ProfileIcon("help_outline", Icons.AutoMirrored.Filled.HelpOutline, "Help Outline"), + ProfileIcon("hide_source", Icons.Filled.HideSource, "Hide Source"), + ProfileIcon("highlight_alt", Icons.Filled.HighlightAlt, "Highlight Alt"), + ProfileIcon("highlight_off", Icons.Filled.HighlightOff, "Highlight Off"), + ProfileIcon("history", Icons.Filled.History, "History"), + ProfileIcon("history_toggle_off", Icons.Filled.HistoryToggleOff, "History Off"), + ProfileIcon("hls", Icons.Filled.Hls, "HLS"), + ProfileIcon("hls_off", Icons.Filled.HlsOff, "HLS Off"), + ProfileIcon("home", Icons.Filled.Home, "Home"), + ProfileIcon("home_filled", Icons.Filled.Home, "Home Filled"), + ProfileIcon("horizontal_split", Icons.Filled.HorizontalSplit, "Horizontal Split"), + ProfileIcon("hourglass_disabled", Icons.Filled.HourglassDisabled, "Hourglass Disabled"), + ProfileIcon("hourglass_empty", Icons.Filled.HourglassEmpty, "Hourglass Empty"), + ProfileIcon("hourglass_full", Icons.Filled.HourglassFull, "Hourglass Full"), + ProfileIcon("html", Icons.Filled.Html, "HTML"), + ProfileIcon("http", Icons.Filled.Http, "HTTP"), + ProfileIcon("https", Icons.Filled.Https, "HTTPS"), + ProfileIcon("important_devices", Icons.Filled.ImportantDevices, "Important Devices"), + ProfileIcon("info", Icons.Filled.Info, "Info"), + // ProfileIcon("info_outline", Icons.Filled.InfoOutline, "Info Outline"), + ProfileIcon("input", Icons.AutoMirrored.Filled.Input, "Input"), + ProfileIcon("install_desktop", Icons.Filled.InstallDesktop, "Install Desktop"), + ProfileIcon("install_mobile", Icons.Filled.InstallMobile, "Install Mobile"), + ProfileIcon( + "integration_instructions", + Icons.Filled.IntegrationInstructions, + "Integration", + ), + ProfileIcon("invert_colors", Icons.Filled.InvertColors, "Invert Colors"), + ProfileIcon("javascript", Icons.Filled.Javascript, "JavaScript"), + ProfileIcon("join_full", Icons.Filled.JoinFull, "Join Full"), + ProfileIcon("join_inner", Icons.Filled.JoinInner, "Join Inner"), + ProfileIcon("join_left", Icons.Filled.JoinLeft, "Join Left"), + ProfileIcon("join_right", Icons.Filled.JoinRight, "Join Right"), + ProfileIcon("label", Icons.AutoMirrored.Filled.Label, "Label"), + ProfileIcon("label_important", Icons.AutoMirrored.Filled.LabelImportant, "Important"), + ProfileIcon("label_off", Icons.AutoMirrored.Filled.LabelOff, "Label Off"), + ProfileIcon("language", Icons.Filled.Language, "Language"), + ProfileIcon("launch", Icons.AutoMirrored.Filled.Launch, "Launch"), + ProfileIcon("leaderboard", Icons.Filled.Leaderboard, "Leaderboard"), + ProfileIcon("lightbulb", Icons.Filled.Lightbulb, "Lightbulb"), + ProfileIcon("lightbulb_circle", Icons.Filled.LightbulbCircle, "Lightbulb Circle"), + // ProfileIcon("lightbulb_outline", Icons.Filled.LightbulbOutline, "Lightbulb Outline"), + ProfileIcon("line_style", Icons.Filled.LineStyle, "Line Style"), + ProfileIcon("line_weight", Icons.Filled.LineWeight, "Line Weight"), + ProfileIcon("list", Icons.AutoMirrored.Filled.List, "List"), + ProfileIcon("list_alt", Icons.AutoMirrored.Filled.ListAlt, "List Alt"), + ProfileIcon("lock", Icons.Filled.Lock, "Lock"), + ProfileIcon("lock_clock", Icons.Filled.LockClock, "Lock Clock"), + ProfileIcon("lock_open", Icons.Filled.LockOpen, "Lock Open"), + // ProfileIcon("lock_outline", Icons.Filled.LockOutline, "Lock Outline"), + ProfileIcon("lock_person", Icons.Filled.LockPerson, "Lock Person"), + ProfileIcon("lock_reset", Icons.Filled.LockReset, "Lock Reset"), + ProfileIcon("login", Icons.AutoMirrored.Filled.Login, "Login"), + ProfileIcon("logout", Icons.AutoMirrored.Filled.Logout, "Logout"), + ProfileIcon("loyalty", Icons.Filled.Loyalty, "Loyalty"), + ProfileIcon("manage_accounts", Icons.Filled.ManageAccounts, "Manage Accounts"), + ProfileIcon("manage_history", Icons.Filled.ManageHistory, "Manage History"), + ProfileIcon("manage_search", Icons.Filled.ManageSearch, "Manage Search"), + ProfileIcon("mark_as_unread", Icons.Filled.MarkAsUnread, "Mark Unread"), + ProfileIcon("markunread_mailbox", Icons.Filled.MarkunreadMailbox, "Unread Mailbox"), + ProfileIcon("maximize", Icons.Filled.Maximize, "Maximize"), + ProfileIcon("mediation", Icons.Filled.Mediation, "Mediation"), + ProfileIcon("minimize", Icons.Filled.Minimize, "Minimize"), + ProfileIcon("model_training", Icons.Filled.ModelTraining, "Model Training"), + ProfileIcon("next_plan", Icons.AutoMirrored.Filled.NextPlan, "Next Plan"), + ProfileIcon("nightlight", Icons.Filled.Nightlight, "Nightlight"), + ProfileIcon("nightlight_round", Icons.Filled.NightlightRound, "Nightlight Round"), + ProfileIcon("no_accounts", Icons.Filled.NoAccounts, "No Accounts"), + ProfileIcon("not_started", Icons.Filled.NotStarted, "Not Started"), + ProfileIcon("note_add", Icons.AutoMirrored.Filled.NoteAdd, "Note Add"), + ProfileIcon("offline_bolt", Icons.Filled.OfflineBolt, "Offline Bolt"), + ProfileIcon("offline_pin", Icons.Filled.OfflinePin, "Offline Pin"), + ProfileIcon("online_prediction", Icons.Filled.OnlinePrediction, "Online Prediction"), + ProfileIcon("opacity", Icons.Filled.Opacity, "Opacity"), + ProfileIcon("open_in_browser", Icons.Filled.OpenInBrowser, "Open Browser"), + ProfileIcon("open_in_full", Icons.Filled.OpenInFull, "Open Full"), + ProfileIcon("open_in_new", Icons.Filled.OpenInNew, "Open New"), + ProfileIcon("open_in_new_off", Icons.Filled.OpenInNewOff, "Open New Off"), + ProfileIcon("open_with", Icons.Filled.OpenWith, "Open With"), + ProfileIcon("outbond", Icons.Filled.Outbond, "Outbond"), + ProfileIcon("outlet", Icons.Filled.Outlet, "Outlet"), + ProfileIcon("output", Icons.Filled.Output, "Output"), + ProfileIcon("pageview", Icons.Filled.Pageview, "Pageview"), + ProfileIcon("paid", Icons.Filled.Paid, "Paid"), + ProfileIcon("pan_tool", Icons.Filled.PanTool, "Pan Tool"), + ProfileIcon("pan_tool_alt", Icons.Filled.PanToolAlt, "Pan Tool Alt"), + ProfileIcon("payment", Icons.Filled.Payment, "Payment"), + ProfileIcon("pending", Icons.Filled.Pending, "Pending"), + ProfileIcon("pending_actions", Icons.Filled.PendingActions, "Pending Actions"), + ProfileIcon("percent", Icons.Filled.Percent, "Percent"), + ProfileIcon("perm_camera_mic", Icons.Filled.PermCameraMic, "Camera Mic"), + ProfileIcon("perm_contact_calendar", Icons.Filled.PermContactCalendar, "Contact Calendar"), + ProfileIcon("perm_data_setting", Icons.Filled.PermDataSetting, "Data Setting"), + ProfileIcon("perm_device_information", Icons.Filled.PermDeviceInformation, "Device Info"), + ProfileIcon("perm_identity", Icons.Filled.PermIdentity, "Identity"), + ProfileIcon("perm_media", Icons.Filled.PermMedia, "Media"), + ProfileIcon("perm_phone_msg", Icons.Filled.PermPhoneMsg, "Phone Message"), + ProfileIcon("perm_scan_wifi", Icons.Filled.PermScanWifi, "Scan WiFi"), + ProfileIcon("pets", Icons.Filled.Pets, "Pets"), + ProfileIcon("php", Icons.Filled.Php, "PHP"), + ProfileIcon("picture_in_picture", Icons.Filled.PictureInPicture, "Picture in Picture"), + ProfileIcon("picture_in_picture_alt", Icons.Filled.PictureInPictureAlt, "PiP Alt"), + ProfileIcon("pin_end", Icons.Filled.PinEnd, "Pin End"), + ProfileIcon("pin_invoke", Icons.Filled.PinInvoke, "Pin Invoke"), + ProfileIcon("plagiarism", Icons.Filled.Plagiarism, "Plagiarism"), + ProfileIcon("play_for_work", Icons.Filled.PlayForWork, "Play Work"), + ProfileIcon("polymer", Icons.Filled.Polymer, "Polymer"), + ProfileIcon("power_settings_new", Icons.Filled.PowerSettingsNew, "Power"), + ProfileIcon("pregnant_woman", Icons.Filled.PregnantWoman, "Pregnant Woman"), + ProfileIcon("preview", Icons.Filled.Preview, "Preview"), + ProfileIcon("print", Icons.Filled.Print, "Print"), + ProfileIcon("print_disabled", Icons.Filled.PrintDisabled, "Print Disabled"), + ProfileIcon("privacy_tip", Icons.Filled.PrivacyTip, "Privacy Tip"), + ProfileIcon( + "production_quantity_limits", + Icons.Filled.ProductionQuantityLimits, + "Quantity Limits", + ), + ProfileIcon("published_with_changes", Icons.Filled.PublishedWithChanges, "Published"), + ProfileIcon("query_builder", Icons.Filled.QueryBuilder, "Query Builder"), + ProfileIcon("question_answer", Icons.Filled.QuestionAnswer, "Q&A"), + ProfileIcon("question_mark", Icons.Filled.QuestionMark, "Question Mark"), + ProfileIcon("quickreply", Icons.Filled.Quickreply, "Quick Reply"), + ProfileIcon("receipt", Icons.Filled.Receipt, "Receipt"), + ProfileIcon("receipt_long", Icons.AutoMirrored.Filled.ReceiptLong, "Receipt Long"), + ProfileIcon("record_voice_over", Icons.Filled.RecordVoiceOver, "Voice Over"), + ProfileIcon("redeem", Icons.Filled.Redeem, "Redeem"), + ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"), + ProfileIcon("remove_done", Icons.Filled.RemoveDone, "Remove Done"), + ProfileIcon("remove_shopping_cart", Icons.Filled.RemoveShoppingCart, "Remove Cart"), + ProfileIcon("reorder", Icons.Filled.Reorder, "Reorder"), + ProfileIcon("repartition", Icons.Filled.Repartition, "Repartition"), + ProfileIcon("report_problem", Icons.Filled.ReportProblem, "Report Problem"), + ProfileIcon("request_page", Icons.Filled.RequestPage, "Request Page"), + ProfileIcon("request_quote", Icons.Filled.RequestQuote, "Request Quote"), + ProfileIcon("restore", Icons.Filled.Restore, "Restore"), + ProfileIcon("restore_from_trash", Icons.Filled.RestoreFromTrash, "Restore Trash"), + ProfileIcon("restore_page", Icons.Filled.RestorePage, "Restore Page"), + ProfileIcon("rocket", Icons.Filled.Rocket, "Rocket"), + ProfileIcon("rocket_launch", Icons.Filled.RocketLaunch, "Rocket Launch"), + ProfileIcon("room", Icons.Filled.Room, "Room"), + ProfileIcon("rounded_corner", Icons.Filled.RoundedCorner, "Rounded Corner"), + ProfileIcon("rowing", Icons.Filled.Rowing, "Rowing"), + ProfileIcon("rule", Icons.Filled.Rule, "Rule"), + ProfileIcon("satellite_alt", Icons.Filled.SatelliteAlt, "Satellite"), + ProfileIcon("save", Icons.Filled.Save, "Save"), + ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"), + ProfileIcon("save_as", Icons.Filled.SaveAs, "Save As"), + ProfileIcon("saved_search", Icons.Filled.SavedSearch, "Saved Search"), + ProfileIcon("savings", Icons.Filled.Savings, "Savings"), + ProfileIcon("schedule", Icons.Filled.Schedule, "Schedule"), + ProfileIcon("schedule_send", Icons.Filled.ScheduleSend, "Schedule Send"), + ProfileIcon("search", Icons.Filled.Search, "Search"), + ProfileIcon("search_off", Icons.Filled.SearchOff, "Search Off"), + ProfileIcon("segment", Icons.Filled.Segment, "Segment"), + ProfileIcon("send", Icons.AutoMirrored.Filled.Send, "Send"), + ProfileIcon("send_and_archive", Icons.Filled.SendAndArchive, "Send Archive"), + ProfileIcon("sensors", Icons.Filled.Sensors, "Sensors"), + ProfileIcon("sensors_off", Icons.Filled.SensorsOff, "Sensors Off"), + ProfileIcon("settings", Icons.Filled.Settings, "Settings"), + ProfileIcon("settings_accessibility", Icons.Filled.SettingsAccessibility, "Accessibility"), + ProfileIcon("settings_applications", Icons.Filled.SettingsApplications, "Applications"), + ProfileIcon("settings_backup_restore", Icons.Filled.SettingsBackupRestore, "Backup"), + ProfileIcon("settings_bluetooth", Icons.Filled.SettingsBluetooth, "Bluetooth"), + ProfileIcon("settings_brightness", Icons.Filled.SettingsBrightness, "Brightness"), + ProfileIcon("settings_cell", Icons.Filled.SettingsCell, "Cell"), + ProfileIcon("settings_ethernet", Icons.Filled.SettingsEthernet, "Ethernet"), + ProfileIcon("settings_input_antenna", Icons.Filled.SettingsInputAntenna, "Antenna"), + ProfileIcon("settings_input_component", Icons.Filled.SettingsInputComponent, "Component"), + ProfileIcon("settings_input_composite", Icons.Filled.SettingsInputComposite, "Composite"), + ProfileIcon("settings_input_hdmi", Icons.Filled.SettingsInputHdmi, "HDMI"), + ProfileIcon("settings_input_svideo", Icons.Filled.SettingsInputSvideo, "S-Video"), + ProfileIcon("settings_overscan", Icons.Filled.SettingsOverscan, "Overscan"), + ProfileIcon("settings_phone", Icons.Filled.SettingsPhone, "Phone"), + ProfileIcon("settings_power", Icons.Filled.SettingsPower, "Power"), + ProfileIcon("settings_remote", Icons.Filled.SettingsRemote, "Remote"), + ProfileIcon("settings_voice", Icons.Filled.SettingsVoice, "Voice"), + ProfileIcon("shop", Icons.Filled.Shop, "Shop"), + ProfileIcon("shop_2", Icons.Filled.Shop2, "Shop 2"), + ProfileIcon("shop_two", Icons.Filled.ShopTwo, "Shop Two"), + ProfileIcon("shopping_bag", Icons.Filled.ShoppingBag, "Shopping Bag"), + ProfileIcon("shopping_basket", Icons.Filled.ShoppingBasket, "Shopping Basket"), + ProfileIcon("shopping_cart", Icons.Filled.ShoppingCart, "Shopping Cart"), + ProfileIcon("shopping_cart_checkout", Icons.Filled.ShoppingCartCheckout, "Checkout"), + ProfileIcon("smart_button", Icons.Filled.SmartButton, "Smart Button"), + ProfileIcon("source", Icons.Filled.Source, "Source"), + ProfileIcon("space_dashboard", Icons.Filled.SpaceDashboard, "Space Dashboard"), + ProfileIcon("spatial_audio", Icons.Filled.SpatialAudio, "Spatial Audio"), + ProfileIcon("spatial_audio_off", Icons.Filled.SpatialAudioOff, "Spatial Audio Off"), + ProfileIcon("spatial_tracking", Icons.Filled.SpatialTracking, "Spatial Tracking"), + ProfileIcon("speaker_notes", Icons.Filled.SpeakerNotes, "Speaker Notes"), + ProfileIcon("speaker_notes_off", Icons.Filled.SpeakerNotesOff, "Speaker Notes Off"), + ProfileIcon("spellcheck", Icons.Filled.Spellcheck, "Spellcheck"), + ProfileIcon("star_rate", Icons.Filled.StarRate, "Star Rate"), + ProfileIcon("stars", Icons.Filled.Stars, "Stars"), + ProfileIcon("sticky_note_2", Icons.Filled.StickyNote2, "Sticky Note"), + ProfileIcon("store", Icons.Filled.Store, "Store"), + ProfileIcon("subject", Icons.AutoMirrored.Filled.Subject, "Subject"), + ProfileIcon("subtitles_off", Icons.Filled.SubtitlesOff, "Subtitles Off"), + ProfileIcon("supervised_user_circle", Icons.Filled.SupervisedUserCircle, "Supervised User"), + ProfileIcon("supervisor_account", Icons.Filled.SupervisorAccount, "Supervisor"), + ProfileIcon("support", Icons.Filled.Support, "Support"), + ProfileIcon("swap_horiz", Icons.Filled.SwapHoriz, "Swap Horizontal"), + ProfileIcon("swap_horizontal_circle", Icons.Filled.SwapHorizontalCircle, "Swap Circle"), + ProfileIcon("swap_vert", Icons.Filled.SwapVert, "Swap Vertical"), + ProfileIcon( + "swap_vertical_circle", + Icons.Filled.SwapVerticalCircle, + "Swap Vertical Circle", + ), + ProfileIcon("swipe", Icons.Filled.Swipe, "Swipe"), + ProfileIcon("swipe_down", Icons.Filled.SwipeDown, "Swipe Down"), + ProfileIcon("swipe_down_alt", Icons.Filled.SwipeDownAlt, "Swipe Down Alt"), + ProfileIcon("swipe_left", Icons.Filled.SwipeLeft, "Swipe Left"), + ProfileIcon("swipe_left_alt", Icons.Filled.SwipeLeftAlt, "Swipe Left Alt"), + ProfileIcon("swipe_right", Icons.Filled.SwipeRight, "Swipe Right"), + ProfileIcon("swipe_right_alt", Icons.Filled.SwipeRightAlt, "Swipe Right Alt"), + ProfileIcon("swipe_up", Icons.Filled.SwipeUp, "Swipe Up"), + ProfileIcon("swipe_up_alt", Icons.Filled.SwipeUpAlt, "Swipe Up Alt"), + ProfileIcon("swipe_vertical", Icons.Filled.SwipeVertical, "Swipe Vertical"), + ProfileIcon("switch_access_shortcut", Icons.Filled.SwitchAccessShortcut, "Switch Shortcut"), + ProfileIcon( + "switch_access_shortcut_add", + Icons.Filled.SwitchAccessShortcutAdd, + "Add Shortcut", + ), + ProfileIcon("sync_alt", Icons.Filled.SyncAlt, "Sync Alt"), + ProfileIcon("system_update_alt", Icons.Filled.SystemUpdateAlt, "System Update"), + ProfileIcon("tab", Icons.Filled.Tab, "Tab"), + ProfileIcon("tab_unselected", Icons.Filled.TabUnselected, "Tab Unselected"), + ProfileIcon("table_view", Icons.Filled.TableView, "Table View"), + ProfileIcon("tag_faces", Icons.Filled.TagFaces, "Tag Faces"), + ProfileIcon("task_alt", Icons.Filled.TaskAlt, "Task Alt"), + ProfileIcon("terminal", Icons.Filled.Terminal, "Terminal"), + ProfileIcon("text_rotate_up", Icons.Filled.TextRotateUp, "Text Rotate Up"), + ProfileIcon("text_rotate_vertical", Icons.Filled.TextRotateVertical, "Text Vertical"), + ProfileIcon( + "text_rotation_angledown", + Icons.Filled.TextRotationAngledown, + "Text Angledown", + ), + ProfileIcon("text_rotation_angleup", Icons.Filled.TextRotationAngleup, "Text Angleup"), + ProfileIcon("text_rotation_down", Icons.Filled.TextRotationDown, "Text Down"), + ProfileIcon("text_rotation_none", Icons.Filled.TextRotationNone, "Text None"), + ProfileIcon("theaters", Icons.Filled.Theaters, "Theaters"), + ProfileIcon("thumb_down", Icons.Filled.ThumbDown, "Thumb Down"), + ProfileIcon("thumb_down_off_alt", Icons.Filled.ThumbDownOffAlt, "Thumb Down Alt"), + ProfileIcon("thumb_up", Icons.Filled.ThumbUp, "Thumb Up"), + ProfileIcon("thumb_up_off_alt", Icons.Filled.ThumbUpOffAlt, "Thumb Up Alt"), + ProfileIcon("thumbs_up_down", Icons.Filled.ThumbsUpDown, "Thumbs Up Down"), + ProfileIcon("timeline", Icons.Filled.Timeline, "Timeline"), + ProfileIcon("tips_and_updates", Icons.Filled.TipsAndUpdates, "Tips & Updates"), + ProfileIcon("toc", Icons.AutoMirrored.Filled.Toc, "Table of Contents"), + ProfileIcon("today", Icons.Filled.Today, "Today"), + ProfileIcon("token", Icons.Filled.Token, "Token"), + ProfileIcon("toll", Icons.Filled.Toll, "Toll"), + ProfileIcon("touch_app", Icons.Filled.TouchApp, "Touch App"), + ProfileIcon("tour", Icons.Filled.Tour, "Tour"), + ProfileIcon("track_changes", Icons.Filled.TrackChanges, "Track Changes"), + ProfileIcon("transcribe", Icons.Filled.Transcribe, "Transcribe"), + ProfileIcon("translate", Icons.Filled.Translate, "Translate"), + ProfileIcon("trending_down", Icons.AutoMirrored.Filled.TrendingDown, "Trending Down"), + ProfileIcon("trending_flat", Icons.AutoMirrored.Filled.TrendingFlat, "Trending Flat"), + ProfileIcon("trending_up", Icons.AutoMirrored.Filled.TrendingUp, "Trending Up"), + ProfileIcon("troubleshoot", Icons.Filled.Troubleshoot, "Troubleshoot"), + // ProfileIcon("try_sms_star", Icons.Filled.TrySmsStar, "Try SMS Star"), + ProfileIcon("turned_in", Icons.Filled.TurnedIn, "Turned In"), + ProfileIcon("turned_in_not", Icons.Filled.TurnedInNot, "Turned In Not"), + ProfileIcon("unfold_less_double", Icons.Filled.UnfoldLessDouble, "Unfold Less Double"), + ProfileIcon("unfold_more_double", Icons.Filled.UnfoldMoreDouble, "Unfold More Double"), + ProfileIcon("unpublished", Icons.Filled.Unpublished, "Unpublished"), + ProfileIcon("update", Icons.Filled.Update, "Update"), + ProfileIcon("update_disabled", Icons.Filled.UpdateDisabled, "Update Disabled"), + ProfileIcon("upgrade", Icons.Filled.Upgrade, "Upgrade"), + ProfileIcon("verified", Icons.Filled.Verified, "Verified"), + ProfileIcon("verified_user", Icons.Filled.VerifiedUser, "Verified User"), + ProfileIcon("vertical_split", Icons.Filled.VerticalSplit, "Vertical Split"), + ProfileIcon("view_agenda", Icons.Filled.ViewAgenda, "View Agenda"), + ProfileIcon("view_array", Icons.Filled.ViewArray, "View Array"), + ProfileIcon("view_carousel", Icons.Filled.ViewCarousel, "View Carousel"), + ProfileIcon("view_column", Icons.Filled.ViewColumn, "View Column"), + ProfileIcon("view_comfy", Icons.Filled.ViewComfy, "View Comfy"), + ProfileIcon("view_comfy_alt", Icons.Filled.ViewComfyAlt, "View Comfy Alt"), + ProfileIcon("view_compact", Icons.Filled.ViewCompact, "View Compact"), + ProfileIcon("view_compact_alt", Icons.Filled.ViewCompactAlt, "View Compact Alt"), + ProfileIcon("view_cozy", Icons.Filled.ViewCozy, "View Cozy"), + ProfileIcon("view_day", Icons.Filled.ViewDay, "View Day"), + ProfileIcon("view_headline", Icons.Filled.ViewHeadline, "View Headline"), + ProfileIcon("view_in_ar", Icons.Filled.ViewInAr, "View in AR"), + ProfileIcon("view_kanban", Icons.Filled.ViewKanban, "View Kanban"), + ProfileIcon("view_list", Icons.AutoMirrored.Filled.ViewList, "View List"), + ProfileIcon("view_module", Icons.Filled.ViewModule, "View Module"), + ProfileIcon("view_quilt", Icons.AutoMirrored.Filled.ViewQuilt, "View Quilt"), + ProfileIcon("view_sidebar", Icons.AutoMirrored.Filled.ViewSidebar, "View Sidebar"), + ProfileIcon("view_stream", Icons.Filled.ViewStream, "View Stream"), + ProfileIcon("view_timeline", Icons.Filled.ViewTimeline, "View Timeline"), + ProfileIcon("view_week", Icons.Filled.ViewWeek, "View Week"), + ProfileIcon("visibility", Icons.Filled.Visibility, "Visibility"), + ProfileIcon("visibility_off", Icons.Filled.VisibilityOff, "Visibility Off"), + ProfileIcon("voice_over_off", Icons.Filled.VoiceOverOff, "Voice Over Off"), + ProfileIcon("watch_later", Icons.Filled.WatchLater, "Watch Later"), + ProfileIcon("webhook", Icons.Filled.Webhook, "Webhook"), + ProfileIcon("width_full", Icons.Filled.WidthFull, "Width Full"), + ProfileIcon("width_normal", Icons.Filled.WidthNormal, "Width Normal"), + ProfileIcon("width_wide", Icons.Filled.WidthWide, "Width Wide"), + ProfileIcon("wifi_protected_setup", Icons.Filled.WifiProtectedSetup, "WiFi Setup"), + ProfileIcon("work", Icons.Filled.Work, "Work"), + ProfileIcon("work_history", Icons.Filled.WorkHistory, "Work History"), + ProfileIcon("work_off", Icons.Filled.WorkOff, "Work Off"), + ProfileIcon("work_outline", Icons.Filled.WorkOutline, "Work Outline"), + ProfileIcon("wysiwyg", Icons.Filled.Wysiwyg, "WYSIWYG"), + ProfileIcon("zoom_in", Icons.Filled.ZoomIn, "Zoom In"), + ProfileIcon("zoom_out", Icons.Filled.ZoomOut, "Zoom Out"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt new file mode 100644 index 0000000000..216e951c4f --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/AlertIcons.kt @@ -0,0 +1,28 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddAlert +import androidx.compose.material.icons.filled.AutoDelete +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.NotificationImportant +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material.icons.filled.WarningAmber +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Alert category icons - Warnings, errors, and notifications + * Based on Google's Material Design Icons taxonomy + */ +object AlertIcons { + val icons = + listOf( + ProfileIcon("add_alert", Icons.Filled.AddAlert, "Add Alert"), + ProfileIcon("auto_delete", Icons.Filled.AutoDelete, "Auto Delete"), + ProfileIcon("error", Icons.Filled.Error, "Error"), + ProfileIcon("error_outline", Icons.Filled.ErrorOutline, "Error Outline"), + ProfileIcon("notification_important", Icons.Filled.NotificationImportant, "Important"), + ProfileIcon("warning", Icons.Filled.Warning, "Warning"), + ProfileIcon("warning_amber", Icons.Filled.WarningAmber, "Warning Amber"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt new file mode 100644 index 0000000000..fda3847b68 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/CommunicationIcons.kt @@ -0,0 +1,218 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.ListAlt +import androidx.compose.material.icons.automirrored.filled.LiveHelp +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.automirrored.filled.ReadMore +import androidx.compose.material.icons.filled.AddIcCall +import androidx.compose.material.icons.filled.AlternateEmail +import androidx.compose.material.icons.filled.AppRegistration +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.CallEnd +import androidx.compose.material.icons.filled.CallMade +import androidx.compose.material.icons.filled.CallMerge +import androidx.compose.material.icons.filled.CallMissed +import androidx.compose.material.icons.filled.CallMissedOutgoing +import androidx.compose.material.icons.filled.CallReceived +import androidx.compose.material.icons.filled.CallSplit +import androidx.compose.material.icons.filled.CancelPresentation +import androidx.compose.material.icons.filled.CellWifi +import androidx.compose.material.icons.filled.ChatBubble +import androidx.compose.material.icons.filled.ChatBubbleOutline +import androidx.compose.material.icons.filled.ClearAll +import androidx.compose.material.icons.filled.CoPresent +import androidx.compose.material.icons.filled.Comment +import androidx.compose.material.icons.filled.CommentsDisabled +import androidx.compose.material.icons.filled.ContactEmergency +import androidx.compose.material.icons.filled.ContactMail +import androidx.compose.material.icons.filled.ContactPhone +import androidx.compose.material.icons.filled.Contacts +import androidx.compose.material.icons.filled.DesktopAccessDisabled +import androidx.compose.material.icons.filled.DialerSip +import androidx.compose.material.icons.filled.Dialpad +import androidx.compose.material.icons.filled.DocumentScanner +import androidx.compose.material.icons.filled.DomainDisabled +import androidx.compose.material.icons.filled.DomainVerification +import androidx.compose.material.icons.filled.Duo +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.ForwardToInbox +import androidx.compose.material.icons.filled.HourglassBottom +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.ImportContacts +import androidx.compose.material.icons.filled.ImportExport +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.InvertColorsOff +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.KeyOff +import androidx.compose.material.icons.filled.LocationOff +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.MailLock +import androidx.compose.material.icons.filled.MailOutline +import androidx.compose.material.icons.filled.MarkChatRead +import androidx.compose.material.icons.filled.MarkChatUnread +import androidx.compose.material.icons.filled.MarkEmailRead +import androidx.compose.material.icons.filled.MarkEmailUnread +import androidx.compose.material.icons.filled.MarkUnreadChatAlt +import androidx.compose.material.icons.filled.MobileScreenShare +import androidx.compose.material.icons.filled.MoreTime +import androidx.compose.material.icons.filled.Nat +import androidx.compose.material.icons.filled.NoSim +import androidx.compose.material.icons.filled.PausePresentation +import androidx.compose.material.icons.filled.PersonAddDisabled +import androidx.compose.material.icons.filled.PersonSearch +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.PhoneDisabled +import androidx.compose.material.icons.filled.PhoneEnabled +import androidx.compose.material.icons.filled.PhonelinkErase +import androidx.compose.material.icons.filled.PhonelinkLock +import androidx.compose.material.icons.filled.PhonelinkRing +import androidx.compose.material.icons.filled.PhonelinkSetup +import androidx.compose.material.icons.filled.PortableWifiOff +import androidx.compose.material.icons.filled.PresentToAll +import androidx.compose.material.icons.filled.PrintDisabled +import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.QrCode2 +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.RingVolume +import androidx.compose.material.icons.filled.RssFeed +import androidx.compose.material.icons.filled.Rtt +import androidx.compose.material.icons.filled.ScreenShare +import androidx.compose.material.icons.filled.SendTimeExtension +import androidx.compose.material.icons.filled.SentimentSatisfiedAlt +import androidx.compose.material.icons.filled.Sip +import androidx.compose.material.icons.filled.SpeakerPhone +import androidx.compose.material.icons.filled.Spoke +import androidx.compose.material.icons.filled.StayCurrentLandscape +import androidx.compose.material.icons.filled.StayCurrentPortrait +import androidx.compose.material.icons.filled.StayPrimaryLandscape +import androidx.compose.material.icons.filled.StayPrimaryPortrait +import androidx.compose.material.icons.filled.StopScreenShare +import androidx.compose.material.icons.filled.SwapCalls +import androidx.compose.material.icons.filled.Textsms +import androidx.compose.material.icons.filled.Unsubscribe +import androidx.compose.material.icons.filled.Voicemail +import androidx.compose.material.icons.filled.VpnKey +import androidx.compose.material.icons.filled.VpnKeyOff +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Communication category icons - Messaging, calls, emails + * Based on Google's Material Design Icons taxonomy + */ +object CommunicationIcons { + val icons = + listOf( + ProfileIcon("add_ic_call", Icons.Filled.AddIcCall, "Add Call"), + ProfileIcon("alternate_email", Icons.Filled.AlternateEmail, "Alt Email"), + ProfileIcon("app_registration", Icons.Filled.AppRegistration, "App Registration"), + ProfileIcon("business", Icons.Filled.Business, "Business"), + ProfileIcon("call", Icons.Filled.Call, "Call"), + ProfileIcon("call_end", Icons.Filled.CallEnd, "Call End"), + ProfileIcon("call_made", Icons.Filled.CallMade, "Call Made"), + ProfileIcon("call_merge", Icons.Filled.CallMerge, "Call Merge"), + ProfileIcon("call_missed", Icons.Filled.CallMissed, "Call Missed"), + ProfileIcon("call_missed_outgoing", Icons.Filled.CallMissedOutgoing, "Missed Outgoing"), + ProfileIcon("call_received", Icons.Filled.CallReceived, "Call Received"), + ProfileIcon("call_split", Icons.Filled.CallSplit, "Call Split"), + ProfileIcon("cancel_presentation", Icons.Filled.CancelPresentation, "Cancel Presentation"), + ProfileIcon("cell_wifi", Icons.Filled.CellWifi, "Cell WiFi"), + ProfileIcon("chat", Icons.AutoMirrored.Filled.Chat, "Chat"), + ProfileIcon("chat_bubble", Icons.Filled.ChatBubble, "Chat Bubble"), + ProfileIcon("chat_bubble_outline", Icons.Filled.ChatBubbleOutline, "Chat Outline"), + ProfileIcon("clear_all", Icons.Filled.ClearAll, "Clear All"), + ProfileIcon("co_present", Icons.Filled.CoPresent, "Co-Present"), + ProfileIcon("comment", Icons.Filled.Comment, "Comment"), + ProfileIcon("comments_disabled", Icons.Filled.CommentsDisabled, "Comments Disabled"), + ProfileIcon("contact_emergency", Icons.Filled.ContactEmergency, "Emergency Contact"), + ProfileIcon("contact_mail", Icons.Filled.ContactMail, "Contact Mail"), + ProfileIcon("contact_phone", Icons.Filled.ContactPhone, "Contact Phone"), + ProfileIcon("contacts", Icons.Filled.Contacts, "Contacts"), + ProfileIcon( + "desktop_access_disabled", + Icons.Filled.DesktopAccessDisabled, + "Desktop Disabled", + ), + ProfileIcon("dialer_sip", Icons.Filled.DialerSip, "Dialer SIP"), + ProfileIcon("dialpad", Icons.Filled.Dialpad, "Dialpad"), + ProfileIcon("document_scanner", Icons.Filled.DocumentScanner, "Document Scanner"), + ProfileIcon("domain_disabled", Icons.Filled.DomainDisabled, "Domain Disabled"), + ProfileIcon("domain_verification", Icons.Filled.DomainVerification, "Domain Verification"), + ProfileIcon("duo", Icons.Filled.Duo, "Duo"), + ProfileIcon("email", Icons.Filled.Email, "Email"), + ProfileIcon("forward_to_inbox", Icons.Filled.ForwardToInbox, "Forward to Inbox"), + ProfileIcon("forum", Icons.Filled.Forum, "Forum"), + ProfileIcon("hourglass_bottom", Icons.Filled.HourglassBottom, "Hourglass Bottom"), + ProfileIcon("hourglass_top", Icons.Filled.HourglassTop, "Hourglass Top"), + ProfileIcon("hub", Icons.Filled.Hub, "Hub"), + ProfileIcon("import_contacts", Icons.Filled.ImportContacts, "Import Contacts"), + ProfileIcon("import_export", Icons.Filled.ImportExport, "Import Export"), + ProfileIcon("inbox", Icons.Filled.Inbox, "Inbox"), + ProfileIcon("invert_colors_off", Icons.Filled.InvertColorsOff, "Invert Colors Off"), + ProfileIcon("key", Icons.Filled.Key, "Key"), + ProfileIcon("key_off", Icons.Filled.KeyOff, "Key Off"), + ProfileIcon("list_alt", Icons.AutoMirrored.Filled.ListAlt, "List Alt"), + ProfileIcon("live_help", Icons.AutoMirrored.Filled.LiveHelp, "Live Help"), + ProfileIcon("location_off", Icons.Filled.LocationOff, "Location Off"), + ProfileIcon("location_on", Icons.Filled.LocationOn, "Location On"), + ProfileIcon("mail", Icons.Filled.Mail, "Mail"), + ProfileIcon("mail_lock", Icons.Filled.MailLock, "Mail Lock"), + ProfileIcon("mail_outline", Icons.Filled.MailOutline, "Mail Outline"), + ProfileIcon("mark_chat_read", Icons.Filled.MarkChatRead, "Mark Chat Read"), + ProfileIcon("mark_chat_unread", Icons.Filled.MarkChatUnread, "Mark Chat Unread"), + ProfileIcon("mark_email_read", Icons.Filled.MarkEmailRead, "Mark Email Read"), + ProfileIcon("mark_email_unread", Icons.Filled.MarkEmailUnread, "Mark Email Unread"), + ProfileIcon("mark_unread_chat_alt", Icons.Filled.MarkUnreadChatAlt, "Mark Unread Alt"), + ProfileIcon("message", Icons.AutoMirrored.Filled.Message, "Message"), + ProfileIcon("mobile_screen_share", Icons.Filled.MobileScreenShare, "Mobile Share"), + ProfileIcon("more_time", Icons.Filled.MoreTime, "More Time"), + ProfileIcon("nat", Icons.Filled.Nat, "NAT"), + ProfileIcon("no_sim", Icons.Filled.NoSim, "No SIM"), + ProfileIcon("pause_presentation", Icons.Filled.PausePresentation, "Pause Presentation"), + ProfileIcon("person_add_disabled", Icons.Filled.PersonAddDisabled, "Person Disabled"), + ProfileIcon("person_search", Icons.Filled.PersonSearch, "Person Search"), + ProfileIcon("phone", Icons.Filled.Phone, "Phone"), + ProfileIcon("phone_disabled", Icons.Filled.PhoneDisabled, "Phone Disabled"), + ProfileIcon("phone_enabled", Icons.Filled.PhoneEnabled, "Phone Enabled"), + ProfileIcon("phonelink_erase", Icons.Filled.PhonelinkErase, "Phone Erase"), + ProfileIcon("phonelink_lock", Icons.Filled.PhonelinkLock, "Phone Lock"), + ProfileIcon("phonelink_ring", Icons.Filled.PhonelinkRing, "Phone Ring"), + ProfileIcon("phonelink_setup", Icons.Filled.PhonelinkSetup, "Phone Setup"), + ProfileIcon("portable_wifi_off", Icons.Filled.PortableWifiOff, "Portable WiFi Off"), + ProfileIcon("present_to_all", Icons.Filled.PresentToAll, "Present to All"), + ProfileIcon("print_disabled", Icons.Filled.PrintDisabled, "Print Disabled"), + ProfileIcon("qr_code", Icons.Filled.QrCode, "QR Code"), + ProfileIcon("qr_code_2", Icons.Filled.QrCode2, "QR Code 2"), + ProfileIcon("qr_code_scanner", Icons.Filled.QrCodeScanner, "QR Scanner"), + ProfileIcon("read_more", Icons.AutoMirrored.Filled.ReadMore, "Read More"), + ProfileIcon("ring_volume", Icons.Filled.RingVolume, "Ring Volume"), + ProfileIcon("rss_feed", Icons.Filled.RssFeed, "RSS Feed"), + ProfileIcon("rtt", Icons.Filled.Rtt, "RTT"), + ProfileIcon("screen_share", Icons.Filled.ScreenShare, "Screen Share"), + ProfileIcon("send_time_extension", Icons.Filled.SendTimeExtension, "Send Extension"), + ProfileIcon("sentiment_satisfied_alt", Icons.Filled.SentimentSatisfiedAlt, "Satisfied"), + ProfileIcon("sip", Icons.Filled.Sip, "SIP"), + ProfileIcon("speaker_phone", Icons.Filled.SpeakerPhone, "Speaker Phone"), + ProfileIcon("spoke", Icons.Filled.Spoke, "Spoke"), + ProfileIcon("stay_current_landscape", Icons.Filled.StayCurrentLandscape, "Stay Landscape"), + ProfileIcon("stay_current_portrait", Icons.Filled.StayCurrentPortrait, "Stay Portrait"), + ProfileIcon( + "stay_primary_landscape", + Icons.Filled.StayPrimaryLandscape, + "Primary Landscape", + ), + ProfileIcon("stay_primary_portrait", Icons.Filled.StayPrimaryPortrait, "Primary Portrait"), + ProfileIcon("stop_screen_share", Icons.Filled.StopScreenShare, "Stop Screen Share"), + ProfileIcon("swap_calls", Icons.Filled.SwapCalls, "Swap Calls"), + ProfileIcon("textsms", Icons.Filled.Textsms, "Text SMS"), + ProfileIcon("unsubscribe", Icons.Filled.Unsubscribe, "Unsubscribe"), + ProfileIcon("voicemail", Icons.Filled.Voicemail, "Voicemail"), + ProfileIcon("vpn_key", Icons.Filled.VpnKey, "VPN Key"), + ProfileIcon("vpn_key_off", Icons.Filled.VpnKeyOff, "VPN Key Off"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt new file mode 100644 index 0000000000..938ad510b8 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ContentIcons.kt @@ -0,0 +1,187 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Backspace +import androidx.compose.material.icons.automirrored.filled.NextWeek +import androidx.compose.material.icons.automirrored.filled.Redo +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AddBox +import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material.icons.filled.AddCircleOutline +import androidx.compose.material.icons.filled.AddLink +import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Ballot +import androidx.compose.material.icons.filled.Biotech +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.Calculate +import androidx.compose.material.icons.filled.ChangeCircle +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.ContentCut +import androidx.compose.material.icons.filled.ContentPaste +import androidx.compose.material.icons.filled.ContentPasteGo +import androidx.compose.material.icons.filled.ContentPasteOff +import androidx.compose.material.icons.filled.ContentPasteSearch +import androidx.compose.material.icons.filled.CopyAll +import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.Deselect +import androidx.compose.material.icons.filled.Drafts +import androidx.compose.material.icons.filled.DynamicFeed +import androidx.compose.material.icons.filled.FileCopy +import androidx.compose.material.icons.filled.Filter1 +import androidx.compose.material.icons.filled.Filter2 +import androidx.compose.material.icons.filled.Filter3 +import androidx.compose.material.icons.filled.Filter4 +import androidx.compose.material.icons.filled.Filter5 +import androidx.compose.material.icons.filled.Filter6 +import androidx.compose.material.icons.filled.Filter7 +import androidx.compose.material.icons.filled.Filter8 +import androidx.compose.material.icons.filled.Filter9 +import androidx.compose.material.icons.filled.Filter9Plus +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.FlagCircle +import androidx.compose.material.icons.filled.FontDownload +import androidx.compose.material.icons.filled.FontDownloadOff +import androidx.compose.material.icons.filled.Forward +import androidx.compose.material.icons.filled.Gesture +import androidx.compose.material.icons.filled.HowToReg +import androidx.compose.material.icons.filled.HowToVote +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.Insights +import androidx.compose.material.icons.filled.Inventory +import androidx.compose.material.icons.filled.Inventory2 +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.LinkOff +import androidx.compose.material.icons.filled.LowPriority +import androidx.compose.material.icons.filled.Mail +import androidx.compose.material.icons.filled.Markunread +import androidx.compose.material.icons.filled.MoveToInbox +import androidx.compose.material.icons.filled.OutlinedFlag +import androidx.compose.material.icons.filled.Policy +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.RemoveCircle +import androidx.compose.material.icons.filled.RemoveCircleOutline +import androidx.compose.material.icons.filled.Reply +import androidx.compose.material.icons.filled.ReplyAll +import androidx.compose.material.icons.filled.Report +import androidx.compose.material.icons.filled.ReportGmailerrorred +import androidx.compose.material.icons.filled.ReportOff +import androidx.compose.material.icons.filled.Save +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material.icons.filled.SaveAs +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Shield +import androidx.compose.material.icons.filled.SquareFoot +import androidx.compose.material.icons.filled.StackedBarChart +import androidx.compose.material.icons.filled.Stream +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material.icons.filled.TextFormat +import androidx.compose.material.icons.filled.Unarchive +import androidx.compose.material.icons.filled.Upcoming +import androidx.compose.material.icons.filled.Waves +import androidx.compose.material.icons.filled.WebStories +import androidx.compose.material.icons.filled.Weekend +import androidx.compose.material.icons.filled.WhereToVote +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Content category icons - Content creation and management + * Based on Google's Material Design Icons taxonomy + */ +object ContentIcons { + val icons = + listOf( + ProfileIcon("add", Icons.Filled.Add, "Add"), + ProfileIcon("add_box", Icons.Filled.AddBox, "Add Box"), + ProfileIcon("add_circle", Icons.Filled.AddCircle, "Add Circle"), + ProfileIcon("add_circle_outline", Icons.Filled.AddCircleOutline, "Add Outline"), + ProfileIcon("add_link", Icons.Filled.AddLink, "Add Link"), + ProfileIcon("archive", Icons.Filled.Archive, "Archive"), + ProfileIcon("backspace", Icons.AutoMirrored.Filled.Backspace, "Backspace"), + ProfileIcon("ballot", Icons.Filled.Ballot, "Ballot"), + ProfileIcon("biotech", Icons.Filled.Biotech, "Biotech"), + ProfileIcon("block", Icons.Filled.Block, "Block"), + ProfileIcon("block_flipped", Icons.Filled.Block, "Block Flipped"), + ProfileIcon("bolt", Icons.Filled.Bolt, "Bolt"), + ProfileIcon("calculate", Icons.Filled.Calculate, "Calculate"), + ProfileIcon("change_circle", Icons.Filled.ChangeCircle, "Change Circle"), + ProfileIcon("clear", Icons.Filled.Clear, "Clear"), + ProfileIcon("content_copy", Icons.Filled.ContentCopy, "Copy"), + ProfileIcon("content_cut", Icons.Filled.ContentCut, "Cut"), + ProfileIcon("content_paste", Icons.Filled.ContentPaste, "Paste"), + ProfileIcon("content_paste_go", Icons.Filled.ContentPasteGo, "Paste Go"), + ProfileIcon("content_paste_off", Icons.Filled.ContentPasteOff, "Paste Off"), + ProfileIcon("content_paste_search", Icons.Filled.ContentPasteSearch, "Paste Search"), + ProfileIcon("copy_all", Icons.Filled.CopyAll, "Copy All"), + ProfileIcon("create", Icons.Filled.Create, "Create"), + ProfileIcon("deselect", Icons.Filled.Deselect, "Deselect"), + ProfileIcon("drafts", Icons.Filled.Drafts, "Drafts"), + ProfileIcon("dynamic_feed", Icons.Filled.DynamicFeed, "Dynamic Feed"), + ProfileIcon("file_copy", Icons.Filled.FileCopy, "File Copy"), + ProfileIcon("filter_1", Icons.Filled.Filter1, "Filter 1"), + ProfileIcon("filter_2", Icons.Filled.Filter2, "Filter 2"), + ProfileIcon("filter_3", Icons.Filled.Filter3, "Filter 3"), + ProfileIcon("filter_4", Icons.Filled.Filter4, "Filter 4"), + ProfileIcon("filter_5", Icons.Filled.Filter5, "Filter 5"), + ProfileIcon("filter_6", Icons.Filled.Filter6, "Filter 6"), + ProfileIcon("filter_7", Icons.Filled.Filter7, "Filter 7"), + ProfileIcon("filter_8", Icons.Filled.Filter8, "Filter 8"), + ProfileIcon("filter_9", Icons.Filled.Filter9, "Filter 9"), + ProfileIcon("filter_9_plus", Icons.Filled.Filter9Plus, "Filter 9+"), + ProfileIcon("flag", Icons.Filled.Flag, "Flag"), + ProfileIcon("flag_circle", Icons.Filled.FlagCircle, "Flag Circle"), + ProfileIcon("font_download", Icons.Filled.FontDownload, "Font Download"), + ProfileIcon("font_download_off", Icons.Filled.FontDownloadOff, "Font Download Off"), + ProfileIcon("forward", Icons.Filled.Forward, "Forward"), + ProfileIcon("gesture", Icons.Filled.Gesture, "Gesture"), + ProfileIcon("how_to_reg", Icons.Filled.HowToReg, "How to Register"), + ProfileIcon("how_to_vote", Icons.Filled.HowToVote, "How to Vote"), + ProfileIcon("inbox", Icons.Filled.Inbox, "Inbox"), + ProfileIcon("insights", Icons.Filled.Insights, "Insights"), + ProfileIcon("inventory", Icons.Filled.Inventory, "Inventory"), + ProfileIcon("inventory_2", Icons.Filled.Inventory2, "Inventory 2"), + ProfileIcon("link", Icons.Filled.Link, "Link"), + ProfileIcon("link_off", Icons.Filled.LinkOff, "Link Off"), + ProfileIcon("low_priority", Icons.Filled.LowPriority, "Low Priority"), + ProfileIcon("mail", Icons.Filled.Mail, "Mail"), + ProfileIcon("markunread", Icons.Filled.Markunread, "Mark Unread"), + ProfileIcon("move_to_inbox", Icons.Filled.MoveToInbox, "Move to Inbox"), + ProfileIcon("next_week", Icons.AutoMirrored.Filled.NextWeek, "Next Week"), + ProfileIcon("outlined_flag", Icons.Filled.OutlinedFlag, "Outlined Flag"), + ProfileIcon("policy", Icons.Filled.Policy, "Policy"), + ProfileIcon("push_pin", Icons.Filled.PushPin, "Push Pin"), + ProfileIcon("redo", Icons.AutoMirrored.Filled.Redo, "Redo"), + ProfileIcon("remove", Icons.Filled.Remove, "Remove"), + ProfileIcon("remove_circle", Icons.Filled.RemoveCircle, "Remove Circle"), + ProfileIcon("remove_circle_outline", Icons.Filled.RemoveCircleOutline, "Remove Outline"), + ProfileIcon("reply", Icons.Filled.Reply, "Reply"), + ProfileIcon("reply_all", Icons.Filled.ReplyAll, "Reply All"), + ProfileIcon("report", Icons.Filled.Report, "Report"), + ProfileIcon("report_gmailerrorred", Icons.Filled.ReportGmailerrorred, "Report Error"), + ProfileIcon("report_off", Icons.Filled.ReportOff, "Report Off"), + ProfileIcon("save", Icons.Filled.Save, "Save"), + ProfileIcon("save_alt", Icons.Filled.SaveAlt, "Save Alt"), + ProfileIcon("save_as", Icons.Filled.SaveAs, "Save As"), + ProfileIcon("select_all", Icons.Filled.SelectAll, "Select All"), + ProfileIcon("send", Icons.AutoMirrored.Filled.Send, "Send"), + ProfileIcon("shield", Icons.Filled.Shield, "Shield"), + ProfileIcon("sort", Icons.AutoMirrored.Filled.Sort, "Sort"), + ProfileIcon("square_foot", Icons.Filled.SquareFoot, "Square Foot"), + ProfileIcon("stacked_bar_chart", Icons.Filled.StackedBarChart, "Stacked Chart"), + ProfileIcon("stream", Icons.Filled.Stream, "Stream"), + ProfileIcon("tag", Icons.Filled.Tag, "Tag"), + ProfileIcon("text_format", Icons.Filled.TextFormat, "Text Format"), + ProfileIcon("unarchive", Icons.Filled.Unarchive, "Unarchive"), + ProfileIcon("undo", Icons.AutoMirrored.Filled.Undo, "Undo"), + ProfileIcon("upcoming", Icons.Filled.Upcoming, "Upcoming"), + ProfileIcon("waves", Icons.Filled.Waves, "Waves"), + ProfileIcon("web_stories", Icons.Filled.WebStories, "Web Stories"), + ProfileIcon("weekend", Icons.Filled.Weekend, "Weekend"), + ProfileIcon("where_to_vote", Icons.Filled.WhereToVote, "Where to Vote"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt new file mode 100644 index 0000000000..0ba4b4183f --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/DeviceIcons.kt @@ -0,0 +1,469 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.AccessTimeFilled +import androidx.compose.material.icons.filled.AdUnits +import androidx.compose.material.icons.filled.AddAlarm +import androidx.compose.material.icons.filled.AddToHomeScreen +import androidx.compose.material.icons.filled.Air +import androidx.compose.material.icons.filled.AirplaneTicket +import androidx.compose.material.icons.filled.AirplanemodeActive +import androidx.compose.material.icons.filled.AirplanemodeInactive +import androidx.compose.material.icons.filled.Aod +import androidx.compose.material.icons.filled.Battery0Bar +import androidx.compose.material.icons.filled.Battery1Bar +import androidx.compose.material.icons.filled.Battery2Bar +import androidx.compose.material.icons.filled.Battery3Bar +import androidx.compose.material.icons.filled.Battery4Bar +import androidx.compose.material.icons.filled.Battery5Bar +import androidx.compose.material.icons.filled.Battery6Bar +import androidx.compose.material.icons.filled.BatteryAlert +import androidx.compose.material.icons.filled.BatteryChargingFull +import androidx.compose.material.icons.filled.BatteryFull +import androidx.compose.material.icons.filled.BatterySaver +import androidx.compose.material.icons.filled.BatteryStd +import androidx.compose.material.icons.filled.BatteryUnknown +import androidx.compose.material.icons.filled.Bloodtype +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.BluetoothAudio +import androidx.compose.material.icons.filled.BluetoothConnected +import androidx.compose.material.icons.filled.BluetoothDisabled +import androidx.compose.material.icons.filled.BluetoothDrive +import androidx.compose.material.icons.filled.BluetoothSearching +import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.BrightnessHigh +import androidx.compose.material.icons.filled.BrightnessLow +import androidx.compose.material.icons.filled.BrightnessMedium +import androidx.compose.material.icons.filled.Cable +import androidx.compose.material.icons.filled.Cameraswitch +import androidx.compose.material.icons.filled.CreditScore +import androidx.compose.material.icons.filled.DarkMode +import androidx.compose.material.icons.filled.DataSaverOff +import androidx.compose.material.icons.filled.DataSaverOn +import androidx.compose.material.icons.filled.DataUsage +import androidx.compose.material.icons.filled.Dataset +import androidx.compose.material.icons.filled.DatasetLinked +import androidx.compose.material.icons.filled.DeveloperMode +import androidx.compose.material.icons.filled.DeviceThermostat +import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.DevicesFold +import androidx.compose.material.icons.filled.DevicesOther +import androidx.compose.material.icons.filled.Discount +import androidx.compose.material.icons.filled.DoNotDisturbOnTotalSilence +import androidx.compose.material.icons.filled.Dvr +import androidx.compose.material.icons.filled.EMobiledata +import androidx.compose.material.icons.filled.EdgesensorHigh +import androidx.compose.material.icons.filled.EdgesensorLow +import androidx.compose.material.icons.filled.FlashlightOff +import androidx.compose.material.icons.filled.FlashlightOn +import androidx.compose.material.icons.filled.Flourescent +import androidx.compose.material.icons.filled.Fluorescent +import androidx.compose.material.icons.filled.FmdBad +import androidx.compose.material.icons.filled.FmdGood +import androidx.compose.material.icons.filled.GMobiledata +import androidx.compose.material.icons.filled.GppBad +import androidx.compose.material.icons.filled.GppGood +import androidx.compose.material.icons.filled.GppMaybe +import androidx.compose.material.icons.filled.GpsFixed +import androidx.compose.material.icons.filled.GpsNotFixed +import androidx.compose.material.icons.filled.GpsOff +import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.Grid3x3 +import androidx.compose.material.icons.filled.Grid4x4 +import androidx.compose.material.icons.filled.GridGoldenratio +import androidx.compose.material.icons.filled.HMobiledata +import androidx.compose.material.icons.filled.HPlusMobiledata +import androidx.compose.material.icons.filled.HdrAuto +import androidx.compose.material.icons.filled.HdrAutoSelect +import androidx.compose.material.icons.filled.HdrOffSelect +import androidx.compose.material.icons.filled.HdrOnSelect +import androidx.compose.material.icons.filled.Lan +import androidx.compose.material.icons.filled.LensBlur +import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.LocationDisabled +import androidx.compose.material.icons.filled.LocationSearching +import androidx.compose.material.icons.filled.LteMobiledata +import androidx.compose.material.icons.filled.LtePlusMobiledata +import androidx.compose.material.icons.filled.MediaBluetoothOff +import androidx.compose.material.icons.filled.MediaBluetoothOn +import androidx.compose.material.icons.filled.Medication +import androidx.compose.material.icons.filled.MobileFriendly +import androidx.compose.material.icons.filled.MobileOff +import androidx.compose.material.icons.filled.MobiledataOff +import androidx.compose.material.icons.filled.ModeNight +import androidx.compose.material.icons.filled.ModeStandby +import androidx.compose.material.icons.filled.MonitorHeart +import androidx.compose.material.icons.filled.MonitorWeight +import androidx.compose.material.icons.filled.NearbyError +import androidx.compose.material.icons.filled.NearbyOff +import androidx.compose.material.icons.filled.NetworkCell +import androidx.compose.material.icons.filled.NetworkWifi +import androidx.compose.material.icons.filled.NetworkWifi1Bar +import androidx.compose.material.icons.filled.NetworkWifi2Bar +import androidx.compose.material.icons.filled.NetworkWifi3Bar +import androidx.compose.material.icons.filled.Nfc +import androidx.compose.material.icons.filled.Nightlight +import androidx.compose.material.icons.filled.NoteAlt +import androidx.compose.material.icons.filled.Password +import androidx.compose.material.icons.filled.Pattern +import androidx.compose.material.icons.filled.Phishing +import androidx.compose.material.icons.filled.Pin +import androidx.compose.material.icons.filled.PlayLesson +import androidx.compose.material.icons.filled.PriceChange +import androidx.compose.material.icons.filled.PriceCheck +import androidx.compose.material.icons.filled.PunchClock +import androidx.compose.material.icons.filled.Quiz +import androidx.compose.material.icons.filled.RMobiledata +import androidx.compose.material.icons.filled.Radar +import androidx.compose.material.icons.filled.RememberMe +import androidx.compose.material.icons.filled.ResetTv +import androidx.compose.material.icons.filled.RestartAlt +import androidx.compose.material.icons.filled.Reviews +import androidx.compose.material.icons.filled.Rsvp +import androidx.compose.material.icons.filled.ScreenLockLandscape +import androidx.compose.material.icons.filled.ScreenLockPortrait +import androidx.compose.material.icons.filled.ScreenLockRotation +import androidx.compose.material.icons.filled.ScreenRotation +import androidx.compose.material.icons.filled.ScreenSearchDesktop +import androidx.compose.material.icons.filled.Screenshot +import androidx.compose.material.icons.filled.ScreenshotMonitor +import androidx.compose.material.icons.filled.SdStorage +import androidx.compose.material.icons.filled.SecurityUpdate +import androidx.compose.material.icons.filled.SecurityUpdateGood +import androidx.compose.material.icons.filled.SecurityUpdateWarning +import androidx.compose.material.icons.filled.Sell +import androidx.compose.material.icons.filled.SendToMobile +import androidx.compose.material.icons.filled.SettingsSuggest +import androidx.compose.material.icons.filled.SettingsSystemDaydream +import androidx.compose.material.icons.filled.ShareLocation +import androidx.compose.material.icons.filled.Shortcut +import androidx.compose.material.icons.filled.SignalCellular0Bar +import androidx.compose.material.icons.filled.SignalCellular4Bar +import androidx.compose.material.icons.filled.SignalCellularAlt +import androidx.compose.material.icons.filled.SignalCellularAlt1Bar +import androidx.compose.material.icons.filled.SignalCellularAlt2Bar +import androidx.compose.material.icons.filled.SignalCellularConnectedNoInternet0Bar +import androidx.compose.material.icons.filled.SignalCellularConnectedNoInternet4Bar +import androidx.compose.material.icons.filled.SignalCellularNoSim +import androidx.compose.material.icons.filled.SignalCellularNodata +import androidx.compose.material.icons.filled.SignalCellularNull +import androidx.compose.material.icons.filled.SignalCellularOff +import androidx.compose.material.icons.filled.SignalWifi0Bar +import androidx.compose.material.icons.filled.SignalWifi4Bar +import androidx.compose.material.icons.filled.SignalWifi4BarLock +import androidx.compose.material.icons.filled.SignalWifiBad +import androidx.compose.material.icons.filled.SignalWifiConnectedNoInternet4 +import androidx.compose.material.icons.filled.SignalWifiOff +import androidx.compose.material.icons.filled.SignalWifiStatusbar4Bar +import androidx.compose.material.icons.filled.SignalWifiStatusbarConnectedNoInternet4 +import androidx.compose.material.icons.filled.SignalWifiStatusbarNull +import androidx.compose.material.icons.filled.SimCard +import androidx.compose.material.icons.filled.SimCardAlert +import androidx.compose.material.icons.filled.SimCardDownload +import androidx.compose.material.icons.filled.SmartDisplay +import androidx.compose.material.icons.filled.SmartScreen +import androidx.compose.material.icons.filled.SmartToy +import androidx.compose.material.icons.filled.Splitscreen +import androidx.compose.material.icons.filled.SportsScore +import androidx.compose.material.icons.filled.SsidChart +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material.icons.filled.Storm +import androidx.compose.material.icons.filled.Summarize +import androidx.compose.material.icons.filled.SystemSecurityUpdate +import androidx.compose.material.icons.filled.SystemSecurityUpdateGood +import androidx.compose.material.icons.filled.SystemSecurityUpdateWarning +import androidx.compose.material.icons.filled.Task +import androidx.compose.material.icons.filled.Thermostat +import androidx.compose.material.icons.filled.ThermostatAuto +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material.icons.filled.Timer10 +import androidx.compose.material.icons.filled.Timer10Select +import androidx.compose.material.icons.filled.Timer3 +import androidx.compose.material.icons.filled.Timer3Select +import androidx.compose.material.icons.filled.TimerOff +import androidx.compose.material.icons.filled.Tungsten +import androidx.compose.material.icons.filled.Usb +import androidx.compose.material.icons.filled.UsbOff +import androidx.compose.material.icons.filled.Wallpaper +import androidx.compose.material.icons.filled.Water +import androidx.compose.material.icons.filled.Widgets +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.Wifi1Bar +import androidx.compose.material.icons.filled.Wifi2Bar +import androidx.compose.material.icons.filled.WifiCalling +import androidx.compose.material.icons.filled.WifiCalling3 +import androidx.compose.material.icons.filled.WifiChannel +import androidx.compose.material.icons.filled.WifiFind +import androidx.compose.material.icons.filled.WifiLock +import androidx.compose.material.icons.filled.WifiOff +import androidx.compose.material.icons.filled.WifiPassword +import androidx.compose.material.icons.filled.WifiProtectedSetup +import androidx.compose.material.icons.filled.WifiTethering +import androidx.compose.material.icons.filled.WifiTetheringError +import androidx.compose.material.icons.filled.WifiTetheringOff +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Device category icons - Device-specific icons and features + * Based on Google's Material Design Icons taxonomy + */ +object DeviceIcons { + val icons = + listOf( + ProfileIcon("access_time", Icons.Filled.AccessTime, "Access Time"), + ProfileIcon("access_time_filled", Icons.Filled.AccessTimeFilled, "Time Filled"), + ProfileIcon("ad_units", Icons.Filled.AdUnits, "Ad Units"), + ProfileIcon("add_alarm", Icons.Filled.AddAlarm, "Add Alarm"), + ProfileIcon("add_to_home_screen", Icons.Filled.AddToHomeScreen, "Add to Home"), + ProfileIcon("air", Icons.Filled.Air, "Air"), + ProfileIcon("airplane_ticket", Icons.Filled.AirplaneTicket, "Airplane Ticket"), + ProfileIcon("airplanemode_active", Icons.Filled.AirplanemodeActive, "Airplane Active"), + ProfileIcon( + "airplanemode_inactive", + Icons.Filled.AirplanemodeInactive, + "Airplane Inactive", + ), + ProfileIcon("aod", Icons.Filled.Aod, "Always On Display"), + ProfileIcon("battery_0_bar", Icons.Filled.Battery0Bar, "Battery 0"), + ProfileIcon("battery_1_bar", Icons.Filled.Battery1Bar, "Battery 1"), + ProfileIcon("battery_2_bar", Icons.Filled.Battery2Bar, "Battery 2"), + ProfileIcon("battery_3_bar", Icons.Filled.Battery3Bar, "Battery 3"), + ProfileIcon("battery_4_bar", Icons.Filled.Battery4Bar, "Battery 4"), + ProfileIcon("battery_5_bar", Icons.Filled.Battery5Bar, "Battery 5"), + ProfileIcon("battery_6_bar", Icons.Filled.Battery6Bar, "Battery 6"), + ProfileIcon("battery_alert", Icons.Filled.BatteryAlert, "Battery Alert"), + ProfileIcon("battery_charging_full", Icons.Filled.BatteryChargingFull, "Charging Full"), + ProfileIcon("battery_full", Icons.Filled.BatteryFull, "Battery Full"), + ProfileIcon("battery_saver", Icons.Filled.BatterySaver, "Battery Saver"), + ProfileIcon("battery_std", Icons.Filled.BatteryStd, "Battery Standard"), + ProfileIcon("battery_unknown", Icons.Filled.BatteryUnknown, "Battery Unknown"), + ProfileIcon("bloodtype", Icons.Filled.Bloodtype, "Blood Type"), + ProfileIcon("bluetooth", Icons.Filled.Bluetooth, "Bluetooth"), + ProfileIcon("bluetooth_audio", Icons.Filled.BluetoothAudio, "Bluetooth Audio"), + ProfileIcon("bluetooth_connected", Icons.Filled.BluetoothConnected, "Bluetooth Connected"), + ProfileIcon("bluetooth_disabled", Icons.Filled.BluetoothDisabled, "Bluetooth Disabled"), + ProfileIcon("bluetooth_drive", Icons.Filled.BluetoothDrive, "Bluetooth Drive"), + ProfileIcon("bluetooth_searching", Icons.Filled.BluetoothSearching, "Bluetooth Searching"), + ProfileIcon("brightness_auto", Icons.Filled.BrightnessAuto, "Brightness Auto"), + ProfileIcon("brightness_high", Icons.Filled.BrightnessHigh, "Brightness High"), + ProfileIcon("brightness_low", Icons.Filled.BrightnessLow, "Brightness Low"), + ProfileIcon("brightness_medium", Icons.Filled.BrightnessMedium, "Brightness Medium"), + ProfileIcon("cable", Icons.Filled.Cable, "Cable"), + ProfileIcon("cameraswitch", Icons.Filled.Cameraswitch, "Camera Switch"), + ProfileIcon("credit_score", Icons.Filled.CreditScore, "Credit Score"), + ProfileIcon("dark_mode", Icons.Filled.DarkMode, "Dark Mode"), + ProfileIcon("data_saver_off", Icons.Filled.DataSaverOff, "Data Saver Off"), + ProfileIcon("data_saver_on", Icons.Filled.DataSaverOn, "Data Saver On"), + ProfileIcon("data_usage", Icons.Filled.DataUsage, "Data Usage"), + ProfileIcon("dataset", Icons.Filled.Dataset, "Dataset"), + ProfileIcon("dataset_linked", Icons.Filled.DatasetLinked, "Dataset Linked"), + ProfileIcon("developer_mode", Icons.Filled.DeveloperMode, "Developer Mode"), + ProfileIcon("device_thermostat", Icons.Filled.DeviceThermostat, "Thermostat"), + ProfileIcon("devices", Icons.Filled.Devices, "Devices"), + ProfileIcon("devices_fold", Icons.Filled.DevicesFold, "Devices Fold"), + ProfileIcon("devices_other", Icons.Filled.DevicesOther, "Devices Other"), + ProfileIcon("discount", Icons.Filled.Discount, "Discount"), + ProfileIcon( + "do_not_disturb_on_total_silence", + Icons.Filled.DoNotDisturbOnTotalSilence, + "DND Total", + ), + ProfileIcon("dvr", Icons.Filled.Dvr, "DVR"), + ProfileIcon("e_mobiledata", Icons.Filled.EMobiledata, "E Mobile Data"), + ProfileIcon("edgesensor_high", Icons.Filled.EdgesensorHigh, "Edge Sensor High"), + ProfileIcon("edgesensor_low", Icons.Filled.EdgesensorLow, "Edge Sensor Low"), + ProfileIcon("flashlight_off", Icons.Filled.FlashlightOff, "Flashlight Off"), + ProfileIcon("flashlight_on", Icons.Filled.FlashlightOn, "Flashlight On"), + ProfileIcon("flourescent", Icons.Filled.Flourescent, "Flourescent"), + ProfileIcon("fluorescent", Icons.Filled.Fluorescent, "Fluorescent"), + ProfileIcon("fmd_bad", Icons.Filled.FmdBad, "Find My Device Bad"), + ProfileIcon("fmd_good", Icons.Filled.FmdGood, "Find My Device Good"), + ProfileIcon("g_mobiledata", Icons.Filled.GMobiledata, "G Mobile Data"), + ProfileIcon("gpp_bad", Icons.Filled.GppBad, "GPP Bad"), + ProfileIcon("gpp_good", Icons.Filled.GppGood, "GPP Good"), + ProfileIcon("gpp_maybe", Icons.Filled.GppMaybe, "GPP Maybe"), + ProfileIcon("gps_fixed", Icons.Filled.GpsFixed, "GPS Fixed"), + ProfileIcon("gps_not_fixed", Icons.Filled.GpsNotFixed, "GPS Not Fixed"), + ProfileIcon("gps_off", Icons.Filled.GpsOff, "GPS Off"), + ProfileIcon("graphic_eq", Icons.Filled.GraphicEq, "Graphic EQ"), + ProfileIcon("grid_3x3", Icons.Filled.Grid3x3, "Grid 3x3"), + ProfileIcon("grid_4x4", Icons.Filled.Grid4x4, "Grid 4x4"), + ProfileIcon("grid_goldenratio", Icons.Filled.GridGoldenratio, "Grid Golden Ratio"), + ProfileIcon("h_mobiledata", Icons.Filled.HMobiledata, "H Mobile Data"), + ProfileIcon("h_plus_mobiledata", Icons.Filled.HPlusMobiledata, "H+ Mobile Data"), + ProfileIcon("hdr_auto", Icons.Filled.HdrAuto, "HDR Auto"), + ProfileIcon("hdr_auto_select", Icons.Filled.HdrAutoSelect, "HDR Auto Select"), + ProfileIcon("hdr_off_select", Icons.Filled.HdrOffSelect, "HDR Off Select"), + ProfileIcon("hdr_on_select", Icons.Filled.HdrOnSelect, "HDR On Select"), + ProfileIcon("lan", Icons.Filled.Lan, "LAN"), + ProfileIcon("lens_blur", Icons.Filled.LensBlur, "Lens Blur"), + ProfileIcon("light_mode", Icons.Filled.LightMode, "Light Mode"), + ProfileIcon("location_disabled", Icons.Filled.LocationDisabled, "Location Disabled"), + ProfileIcon("location_searching", Icons.Filled.LocationSearching, "Location Searching"), + ProfileIcon("lte_mobiledata", Icons.Filled.LteMobiledata, "LTE"), + ProfileIcon("lte_plus_mobiledata", Icons.Filled.LtePlusMobiledata, "LTE+"), + ProfileIcon("media_bluetooth_off", Icons.Filled.MediaBluetoothOff, "Media Bluetooth Off"), + ProfileIcon("media_bluetooth_on", Icons.Filled.MediaBluetoothOn, "Media Bluetooth On"), + ProfileIcon("medication", Icons.Filled.Medication, "Medication"), + // ProfileIcon("medication_liquid", Icons.Filled.MedicationLiquid, "Medication Liquid"), + ProfileIcon("mobile_friendly", Icons.Filled.MobileFriendly, "Mobile Friendly"), + ProfileIcon("mobile_off", Icons.Filled.MobileOff, "Mobile Off"), + ProfileIcon("mobiledata_off", Icons.Filled.MobiledataOff, "Mobile Data Off"), + ProfileIcon("mode_night", Icons.Filled.ModeNight, "Night Mode"), + ProfileIcon("mode_standby", Icons.Filled.ModeStandby, "Standby Mode"), + ProfileIcon("monitor_heart", Icons.Filled.MonitorHeart, "Monitor Heart"), + ProfileIcon("monitor_weight", Icons.Filled.MonitorWeight, "Monitor Weight"), + ProfileIcon("nearby_error", Icons.Filled.NearbyError, "Nearby Error"), + ProfileIcon("nearby_off", Icons.Filled.NearbyOff, "Nearby Off"), + ProfileIcon("network_cell", Icons.Filled.NetworkCell, "Network Cell"), + ProfileIcon("network_wifi", Icons.Filled.NetworkWifi, "Network WiFi"), + ProfileIcon("network_wifi_1_bar", Icons.Filled.NetworkWifi1Bar, "WiFi 1 Bar"), + ProfileIcon("network_wifi_2_bar", Icons.Filled.NetworkWifi2Bar, "WiFi 2 Bar"), + ProfileIcon("network_wifi_3_bar", Icons.Filled.NetworkWifi3Bar, "WiFi 3 Bar"), + ProfileIcon("nfc", Icons.Filled.Nfc, "NFC"), + ProfileIcon("nightlight", Icons.Filled.Nightlight, "Nightlight"), + ProfileIcon("note_alt", Icons.Filled.NoteAlt, "Note Alt"), + ProfileIcon("password", Icons.Filled.Password, "Password"), + ProfileIcon("pattern", Icons.Filled.Pattern, "Pattern"), + ProfileIcon("phishing", Icons.Filled.Phishing, "Phishing"), + ProfileIcon("pin", Icons.Filled.Pin, "PIN"), + ProfileIcon("play_lesson", Icons.Filled.PlayLesson, "Play Lesson"), + ProfileIcon("price_change", Icons.Filled.PriceChange, "Price Change"), + ProfileIcon("price_check", Icons.Filled.PriceCheck, "Price Check"), + ProfileIcon("punch_clock", Icons.Filled.PunchClock, "Punch Clock"), + ProfileIcon("quiz", Icons.Filled.Quiz, "Quiz"), + ProfileIcon("r_mobiledata", Icons.Filled.RMobiledata, "R Mobile Data"), + ProfileIcon("radar", Icons.Filled.Radar, "Radar"), + ProfileIcon("remember_me", Icons.Filled.RememberMe, "Remember Me"), + ProfileIcon("reset_tv", Icons.Filled.ResetTv, "Reset TV"), + ProfileIcon("restart_alt", Icons.Filled.RestartAlt, "Restart"), + ProfileIcon("reviews", Icons.Filled.Reviews, "Reviews"), + ProfileIcon("rsvp", Icons.Filled.Rsvp, "RSVP"), + ProfileIcon("screen_lock_landscape", Icons.Filled.ScreenLockLandscape, "Lock Landscape"), + ProfileIcon("screen_lock_portrait", Icons.Filled.ScreenLockPortrait, "Lock Portrait"), + ProfileIcon("screen_lock_rotation", Icons.Filled.ScreenLockRotation, "Lock Rotation"), + ProfileIcon("screen_rotation", Icons.Filled.ScreenRotation, "Screen Rotation"), + ProfileIcon("screen_search_desktop", Icons.Filled.ScreenSearchDesktop, "Screen Search"), + ProfileIcon("screenshot", Icons.Filled.Screenshot, "Screenshot"), + ProfileIcon("screenshot_monitor", Icons.Filled.ScreenshotMonitor, "Screenshot Monitor"), + ProfileIcon("sd_storage", Icons.Filled.SdStorage, "SD Storage"), + ProfileIcon("security_update", Icons.Filled.SecurityUpdate, "Security Update"), + ProfileIcon("security_update_good", Icons.Filled.SecurityUpdateGood, "Security Good"), + ProfileIcon( + "security_update_warning", + Icons.Filled.SecurityUpdateWarning, + "Security Warning", + ), + ProfileIcon("sell", Icons.Filled.Sell, "Sell"), + ProfileIcon("send_to_mobile", Icons.Filled.SendToMobile, "Send to Mobile"), + ProfileIcon("settings_suggest", Icons.Filled.SettingsSuggest, "Settings Suggest"), + ProfileIcon("settings_system_daydream", Icons.Filled.SettingsSystemDaydream, "Daydream"), + ProfileIcon("share_location", Icons.Filled.ShareLocation, "Share Location"), + ProfileIcon("shortcut", Icons.Filled.Shortcut, "Shortcut"), + ProfileIcon("signal_cellular_0_bar", Icons.Filled.SignalCellular0Bar, "Signal 0"), + ProfileIcon("signal_cellular_4_bar", Icons.Filled.SignalCellular4Bar, "Signal 4"), + ProfileIcon("signal_cellular_alt", Icons.Filled.SignalCellularAlt, "Signal Alt"), + ProfileIcon( + "signal_cellular_alt_1_bar", + Icons.Filled.SignalCellularAlt1Bar, + "Signal Alt 1", + ), + ProfileIcon( + "signal_cellular_alt_2_bar", + Icons.Filled.SignalCellularAlt2Bar, + "Signal Alt 2", + ), + ProfileIcon( + "signal_cellular_connected_no_internet_0_bar", + Icons.Filled.SignalCellularConnectedNoInternet0Bar, + "No Internet", + ), + ProfileIcon( + "signal_cellular_connected_no_internet_4_bar", + Icons.Filled.SignalCellularConnectedNoInternet4Bar, + "No Internet 4", + ), + ProfileIcon("signal_cellular_no_sim", Icons.Filled.SignalCellularNoSim, "No SIM"), + ProfileIcon("signal_cellular_nodata", Icons.Filled.SignalCellularNodata, "No Data"), + ProfileIcon("signal_cellular_null", Icons.Filled.SignalCellularNull, "Signal Null"), + ProfileIcon("signal_cellular_off", Icons.Filled.SignalCellularOff, "Signal Off"), + ProfileIcon("signal_wifi_0_bar", Icons.Filled.SignalWifi0Bar, "WiFi 0"), + ProfileIcon("signal_wifi_4_bar", Icons.Filled.SignalWifi4Bar, "WiFi 4"), + ProfileIcon("signal_wifi_4_bar_lock", Icons.Filled.SignalWifi4BarLock, "WiFi Lock"), + ProfileIcon("signal_wifi_bad", Icons.Filled.SignalWifiBad, "WiFi Bad"), + ProfileIcon( + "signal_wifi_connected_no_internet_4", + Icons.Filled.SignalWifiConnectedNoInternet4, + "WiFi No Internet", + ), + ProfileIcon("signal_wifi_off", Icons.Filled.SignalWifiOff, "WiFi Off"), + ProfileIcon( + "signal_wifi_statusbar_4_bar", + Icons.Filled.SignalWifiStatusbar4Bar, + "WiFi Status 4", + ), + ProfileIcon( + "signal_wifi_statusbar_connected_no_internet_4", + Icons.Filled.SignalWifiStatusbarConnectedNoInternet4, + "WiFi Status No Internet", + ), + ProfileIcon( + "signal_wifi_statusbar_null", + Icons.Filled.SignalWifiStatusbarNull, + "WiFi Status Null", + ), + ProfileIcon("sim_card", Icons.Filled.SimCard, "SIM Card"), + ProfileIcon("sim_card_alert", Icons.Filled.SimCardAlert, "SIM Alert"), + ProfileIcon("sim_card_download", Icons.Filled.SimCardDownload, "SIM Download"), + ProfileIcon("smart_display", Icons.Filled.SmartDisplay, "Smart Display"), + ProfileIcon("smart_screen", Icons.Filled.SmartScreen, "Smart Screen"), + ProfileIcon("smart_toy", Icons.Filled.SmartToy, "Smart Toy"), + ProfileIcon("splitscreen", Icons.Filled.Splitscreen, "Split Screen"), + ProfileIcon("sports_score", Icons.Filled.SportsScore, "Sports Score"), + ProfileIcon("ssid_chart", Icons.Filled.SsidChart, "SSID Chart"), + ProfileIcon("storage", Icons.Filled.Storage, "Storage"), + ProfileIcon("storm", Icons.Filled.Storm, "Storm"), + ProfileIcon("summarize", Icons.Filled.Summarize, "Summarize"), + ProfileIcon("system_security_update", Icons.Filled.SystemSecurityUpdate, "System Security"), + ProfileIcon( + "system_security_update_good", + Icons.Filled.SystemSecurityUpdateGood, + "System Security Good", + ), + ProfileIcon( + "system_security_update_warning", + Icons.Filled.SystemSecurityUpdateWarning, + "System Warning", + ), + ProfileIcon("task", Icons.Filled.Task, "Task"), + ProfileIcon("thermostat", Icons.Filled.Thermostat, "Thermostat"), + ProfileIcon("thermostat_auto", Icons.Filled.ThermostatAuto, "Thermostat Auto"), + ProfileIcon("timer", Icons.Filled.Timer, "Timer"), + ProfileIcon("timer_10", Icons.Filled.Timer10, "Timer 10"), + ProfileIcon("timer_10_select", Icons.Filled.Timer10Select, "Timer 10 Select"), + ProfileIcon("timer_3", Icons.Filled.Timer3, "Timer 3"), + ProfileIcon("timer_3_select", Icons.Filled.Timer3Select, "Timer 3 Select"), + ProfileIcon("timer_off", Icons.Filled.TimerOff, "Timer Off"), + ProfileIcon("tungsten", Icons.Filled.Tungsten, "Tungsten"), + ProfileIcon("usb", Icons.Filled.Usb, "USB"), + ProfileIcon("usb_off", Icons.Filled.UsbOff, "USB Off"), + ProfileIcon("wallpaper", Icons.Filled.Wallpaper, "Wallpaper"), + ProfileIcon("water", Icons.Filled.Water, "Water"), + ProfileIcon("widgets", Icons.Filled.Widgets, "Widgets"), + ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"), + ProfileIcon("wifi_1_bar", Icons.Filled.Wifi1Bar, "WiFi 1 Bar"), + ProfileIcon("wifi_2_bar", Icons.Filled.Wifi2Bar, "WiFi 2 Bar"), + ProfileIcon("wifi_calling", Icons.Filled.WifiCalling, "WiFi Calling"), + ProfileIcon("wifi_calling_3", Icons.Filled.WifiCalling3, "WiFi Calling 3"), + ProfileIcon("wifi_channel", Icons.Filled.WifiChannel, "WiFi Channel"), + ProfileIcon("wifi_find", Icons.Filled.WifiFind, "WiFi Find"), + ProfileIcon("wifi_lock", Icons.Filled.WifiLock, "WiFi Lock"), + ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"), + ProfileIcon("wifi_password", Icons.Filled.WifiPassword, "WiFi Password"), + ProfileIcon("wifi_protected_setup", Icons.Filled.WifiProtectedSetup, "WiFi Setup"), + ProfileIcon("wifi_tethering", Icons.Filled.WifiTethering, "WiFi Tethering"), + ProfileIcon("wifi_tethering_error", Icons.Filled.WifiTetheringError, "WiFi Error"), + ProfileIcon("wifi_tethering_off", Icons.Filled.WifiTetheringOff, "WiFi Tethering Off"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt new file mode 100644 index 0000000000..0634d4dfa5 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/EditorIcons.kt @@ -0,0 +1,272 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.FormatListBulleted +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.automirrored.filled.ShortText +import androidx.compose.material.icons.automirrored.filled.ShowChart +import androidx.compose.material.icons.automirrored.filled.WrapText +import androidx.compose.material.icons.filled.AddChart +import androidx.compose.material.icons.filled.AddComment +import androidx.compose.material.icons.filled.AlignHorizontalCenter +import androidx.compose.material.icons.filled.AlignHorizontalLeft +import androidx.compose.material.icons.filled.AlignHorizontalRight +import androidx.compose.material.icons.filled.AlignVerticalBottom +import androidx.compose.material.icons.filled.AlignVerticalCenter +import androidx.compose.material.icons.filled.AlignVerticalTop +import androidx.compose.material.icons.filled.AreaChart +import androidx.compose.material.icons.filled.AttachEmail +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.filled.AttachMoney +import androidx.compose.material.icons.filled.AutoGraph +import androidx.compose.material.icons.filled.BarChart +import androidx.compose.material.icons.filled.BorderAll +import androidx.compose.material.icons.filled.BorderBottom +import androidx.compose.material.icons.filled.BorderClear +import androidx.compose.material.icons.filled.BorderColor +import androidx.compose.material.icons.filled.BorderHorizontal +import androidx.compose.material.icons.filled.BorderInner +import androidx.compose.material.icons.filled.BorderLeft +import androidx.compose.material.icons.filled.BorderOuter +import androidx.compose.material.icons.filled.BorderRight +import androidx.compose.material.icons.filled.BorderStyle +import androidx.compose.material.icons.filled.BorderTop +import androidx.compose.material.icons.filled.BorderVertical +import androidx.compose.material.icons.filled.BubbleChart +import androidx.compose.material.icons.filled.CandlestickChart +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.ChecklistRtl +import androidx.compose.material.icons.filled.DataArray +import androidx.compose.material.icons.filled.DataObject +import androidx.compose.material.icons.filled.DragHandle +import androidx.compose.material.icons.filled.Draw +import androidx.compose.material.icons.filled.EditNote +import androidx.compose.material.icons.filled.FormatAlignCenter +import androidx.compose.material.icons.filled.FormatAlignJustify +import androidx.compose.material.icons.filled.FormatAlignLeft +import androidx.compose.material.icons.filled.FormatAlignRight +import androidx.compose.material.icons.filled.FormatBold +import androidx.compose.material.icons.filled.FormatClear +import androidx.compose.material.icons.filled.FormatColorFill +import androidx.compose.material.icons.filled.FormatColorReset +import androidx.compose.material.icons.filled.FormatColorText +import androidx.compose.material.icons.filled.FormatIndentDecrease +import androidx.compose.material.icons.filled.FormatIndentIncrease +import androidx.compose.material.icons.filled.FormatItalic +import androidx.compose.material.icons.filled.FormatLineSpacing +import androidx.compose.material.icons.filled.FormatListNumbered +import androidx.compose.material.icons.filled.FormatListNumberedRtl +import androidx.compose.material.icons.filled.FormatPaint +import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.filled.FormatShapes +import androidx.compose.material.icons.filled.FormatSize +import androidx.compose.material.icons.filled.FormatStrikethrough +import androidx.compose.material.icons.filled.FormatTextdirectionLToR +import androidx.compose.material.icons.filled.FormatTextdirectionRToL +import androidx.compose.material.icons.filled.FormatUnderlined +import androidx.compose.material.icons.filled.Functions +import androidx.compose.material.icons.filled.Height +import androidx.compose.material.icons.filled.Hexagon +import androidx.compose.material.icons.filled.Highlight +import androidx.compose.material.icons.filled.HorizontalDistribute +import androidx.compose.material.icons.filled.HorizontalRule +import androidx.compose.material.icons.filled.InsertChart +import androidx.compose.material.icons.filled.InsertChartOutlined +import androidx.compose.material.icons.filled.InsertComment +import androidx.compose.material.icons.filled.InsertEmoticon +import androidx.compose.material.icons.filled.InsertInvitation +import androidx.compose.material.icons.filled.InsertLink +import androidx.compose.material.icons.filled.InsertPageBreak +import androidx.compose.material.icons.filled.InsertPhoto +import androidx.compose.material.icons.filled.LineAxis +import androidx.compose.material.icons.filled.LineWeight +import androidx.compose.material.icons.filled.LinearScale +import androidx.compose.material.icons.filled.Margin +import androidx.compose.material.icons.filled.MergeType +import androidx.compose.material.icons.filled.Mode +import androidx.compose.material.icons.filled.ModeComment +import androidx.compose.material.icons.filled.ModeEdit +import androidx.compose.material.icons.filled.ModeEditOutline +import androidx.compose.material.icons.filled.MonetizationOn +import androidx.compose.material.icons.filled.MoneyOff +import androidx.compose.material.icons.filled.MoneyOffCsred +import androidx.compose.material.icons.filled.MoveDown +import androidx.compose.material.icons.filled.MoveUp +import androidx.compose.material.icons.filled.MultilineChart +import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.Padding +import androidx.compose.material.icons.filled.Pentagon +import androidx.compose.material.icons.filled.PieChart +import androidx.compose.material.icons.filled.PieChartOutline +import androidx.compose.material.icons.filled.Polyline +import androidx.compose.material.icons.filled.PostAdd +import androidx.compose.material.icons.filled.Publish +import androidx.compose.material.icons.filled.QueryStats +import androidx.compose.material.icons.filled.Rectangle +import androidx.compose.material.icons.filled.ScatterPlot +import androidx.compose.material.icons.filled.Schema +import androidx.compose.material.icons.filled.Score +import androidx.compose.material.icons.filled.SpaceBar +import androidx.compose.material.icons.filled.Square +import androidx.compose.material.icons.filled.StackedLineChart +import androidx.compose.material.icons.filled.StrikethroughS +import androidx.compose.material.icons.filled.Subscript +import androidx.compose.material.icons.filled.Superscript +import androidx.compose.material.icons.filled.TableChart +import androidx.compose.material.icons.filled.TableRows +import androidx.compose.material.icons.filled.TextDecrease +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.material.icons.filled.TextIncrease +import androidx.compose.material.icons.filled.Title +import androidx.compose.material.icons.filled.VerticalAlignBottom +import androidx.compose.material.icons.filled.VerticalAlignCenter +import androidx.compose.material.icons.filled.VerticalAlignTop +import androidx.compose.material.icons.filled.VerticalDistribute +import androidx.compose.material.icons.filled.WaterfallChart +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Editor category icons - Text and content editing + * Based on Google's Material Design Icons taxonomy + */ +object EditorIcons { + val icons = + listOf( + ProfileIcon("add_chart", Icons.Filled.AddChart, "Add Chart"), + ProfileIcon("add_comment", Icons.Filled.AddComment, "Add Comment"), + ProfileIcon("align_horizontal_center", Icons.Filled.AlignHorizontalCenter, "Align Center"), + ProfileIcon("align_horizontal_left", Icons.Filled.AlignHorizontalLeft, "Align Left"), + ProfileIcon("align_horizontal_right", Icons.Filled.AlignHorizontalRight, "Align Right"), + ProfileIcon("align_vertical_bottom", Icons.Filled.AlignVerticalBottom, "Align Bottom"), + ProfileIcon("align_vertical_center", Icons.Filled.AlignVerticalCenter, "Align Middle"), + ProfileIcon("align_vertical_top", Icons.Filled.AlignVerticalTop, "Align Top"), + ProfileIcon("area_chart", Icons.Filled.AreaChart, "Area Chart"), + ProfileIcon("attach_email", Icons.Filled.AttachEmail, "Attach Email"), + ProfileIcon("attach_file", Icons.Filled.AttachFile, "Attach File"), + ProfileIcon("attach_money", Icons.Filled.AttachMoney, "Attach Money"), + ProfileIcon("auto_graph", Icons.Filled.AutoGraph, "Auto Graph"), + ProfileIcon("bar_chart", Icons.Filled.BarChart, "Bar Chart"), + ProfileIcon("border_all", Icons.Filled.BorderAll, "Border All"), + ProfileIcon("border_bottom", Icons.Filled.BorderBottom, "Border Bottom"), + ProfileIcon("border_clear", Icons.Filled.BorderClear, "Border Clear"), + ProfileIcon("border_color", Icons.Filled.BorderColor, "Border Color"), + ProfileIcon("border_horizontal", Icons.Filled.BorderHorizontal, "Border Horizontal"), + ProfileIcon("border_inner", Icons.Filled.BorderInner, "Border Inner"), + ProfileIcon("border_left", Icons.Filled.BorderLeft, "Border Left"), + ProfileIcon("border_outer", Icons.Filled.BorderOuter, "Border Outer"), + ProfileIcon("border_right", Icons.Filled.BorderRight, "Border Right"), + ProfileIcon("border_style", Icons.Filled.BorderStyle, "Border Style"), + ProfileIcon("border_top", Icons.Filled.BorderTop, "Border Top"), + ProfileIcon("border_vertical", Icons.Filled.BorderVertical, "Border Vertical"), + ProfileIcon("bubble_chart", Icons.Filled.BubbleChart, "Bubble Chart"), + ProfileIcon("candlestick_chart", Icons.Filled.CandlestickChart, "Candlestick Chart"), + ProfileIcon("checklist", Icons.Filled.Checklist, "Checklist"), + ProfileIcon("checklist_rtl", Icons.Filled.ChecklistRtl, "Checklist RTL"), + ProfileIcon("data_array", Icons.Filled.DataArray, "Data Array"), + ProfileIcon("data_object", Icons.Filled.DataObject, "Data Object"), + ProfileIcon("drag_handle", Icons.Filled.DragHandle, "Drag Handle"), + ProfileIcon("draw", Icons.Filled.Draw, "Draw"), + ProfileIcon("edit_note", Icons.Filled.EditNote, "Edit Note"), + ProfileIcon("format_align_center", Icons.Filled.FormatAlignCenter, "Format Center"), + ProfileIcon("format_align_justify", Icons.Filled.FormatAlignJustify, "Format Justify"), + ProfileIcon("format_align_left", Icons.Filled.FormatAlignLeft, "Format Left"), + ProfileIcon("format_align_right", Icons.Filled.FormatAlignRight, "Format Right"), + ProfileIcon("format_bold", Icons.Filled.FormatBold, "Bold"), + ProfileIcon("format_clear", Icons.Filled.FormatClear, "Format Clear"), + ProfileIcon("format_color_fill", Icons.Filled.FormatColorFill, "Color Fill"), + ProfileIcon("format_color_reset", Icons.Filled.FormatColorReset, "Color Reset"), + ProfileIcon("format_color_text", Icons.Filled.FormatColorText, "Color Text"), + ProfileIcon("format_indent_decrease", Icons.Filled.FormatIndentDecrease, "Indent Less"), + ProfileIcon("format_indent_increase", Icons.Filled.FormatIndentIncrease, "Indent More"), + ProfileIcon("format_italic", Icons.Filled.FormatItalic, "Italic"), + ProfileIcon("format_line_spacing", Icons.Filled.FormatLineSpacing, "Line Spacing"), + ProfileIcon( + "format_list_bulleted", + Icons.AutoMirrored.Filled.FormatListBulleted, + "Bulleted List", + ), + ProfileIcon("format_list_numbered", Icons.Filled.FormatListNumbered, "Numbered List"), + ProfileIcon("format_list_numbered_rtl", Icons.Filled.FormatListNumberedRtl, "List RTL"), + ProfileIcon("format_paint", Icons.Filled.FormatPaint, "Format Paint"), + ProfileIcon("format_quote", Icons.Filled.FormatQuote, "Quote"), + ProfileIcon("format_shapes", Icons.Filled.FormatShapes, "Format Shapes"), + ProfileIcon("format_size", Icons.Filled.FormatSize, "Format Size"), + ProfileIcon("format_strikethrough", Icons.Filled.FormatStrikethrough, "Strikethrough"), + ProfileIcon("format_text_direction_l_to_r", Icons.Filled.FormatTextdirectionLToR, "LTR"), + ProfileIcon("format_text_direction_r_to_l", Icons.Filled.FormatTextdirectionRToL, "RTL"), + ProfileIcon("format_underlined", Icons.Filled.FormatUnderlined, "Underlined"), + ProfileIcon("functions", Icons.Filled.Functions, "Functions"), + ProfileIcon("height", Icons.Filled.Height, "Height"), + ProfileIcon("hexagon", Icons.Filled.Hexagon, "Hexagon"), + ProfileIcon("highlight", Icons.Filled.Highlight, "Highlight"), + ProfileIcon( + "horizontal_distribute", + Icons.Filled.HorizontalDistribute, + "Horizontal Distribute", + ), + ProfileIcon("horizontal_rule", Icons.Filled.HorizontalRule, "Horizontal Rule"), + ProfileIcon("insert_chart", Icons.Filled.InsertChart, "Insert Chart"), + ProfileIcon( + "insert_chart_outlined", + Icons.Filled.InsertChartOutlined, + "Insert Chart Outlined", + ), + ProfileIcon("insert_comment", Icons.Filled.InsertComment, "Insert Comment"), + ProfileIcon("insert_drive_file", Icons.AutoMirrored.Filled.InsertDriveFile, "Insert File"), + ProfileIcon("insert_emoticon", Icons.Filled.InsertEmoticon, "Insert Emoticon"), + ProfileIcon("insert_invitation", Icons.Filled.InsertInvitation, "Insert Invitation"), + ProfileIcon("insert_link", Icons.Filled.InsertLink, "Insert Link"), + ProfileIcon("insert_page_break", Icons.Filled.InsertPageBreak, "Page Break"), + ProfileIcon("insert_photo", Icons.Filled.InsertPhoto, "Insert Photo"), + ProfileIcon("line_axis", Icons.Filled.LineAxis, "Line Axis"), + ProfileIcon("line_weight", Icons.Filled.LineWeight, "Line Weight"), + ProfileIcon("linear_scale", Icons.Filled.LinearScale, "Linear Scale"), + ProfileIcon("margin", Icons.Filled.Margin, "Margin"), + ProfileIcon("merge_type", Icons.Filled.MergeType, "Merge Type"), + ProfileIcon("mode", Icons.Filled.Mode, "Mode"), + ProfileIcon("mode_comment", Icons.Filled.ModeComment, "Mode Comment"), + ProfileIcon("mode_edit", Icons.Filled.ModeEdit, "Mode Edit"), + ProfileIcon("mode_edit_outline", Icons.Filled.ModeEditOutline, "Mode Edit Outline"), + ProfileIcon("monetization_on", Icons.Filled.MonetizationOn, "Monetization On"), + ProfileIcon("money_off", Icons.Filled.MoneyOff, "Money Off"), + ProfileIcon("money_off_csred", Icons.Filled.MoneyOffCsred, "Money Off CS"), + ProfileIcon("move_down", Icons.Filled.MoveDown, "Move Down"), + ProfileIcon("move_up", Icons.Filled.MoveUp, "Move Up"), + ProfileIcon("multiline_chart", Icons.Filled.MultilineChart, "Multiline Chart"), + ProfileIcon("notes", Icons.AutoMirrored.Filled.Notes, "Notes"), + ProfileIcon("numbers", Icons.Filled.Numbers, "Numbers"), + ProfileIcon("padding", Icons.Filled.Padding, "Padding"), + ProfileIcon("pentagon", Icons.Filled.Pentagon, "Pentagon"), + ProfileIcon("pie_chart", Icons.Filled.PieChart, "Pie Chart"), + ProfileIcon("pie_chart_outline", Icons.Filled.PieChartOutline, "Pie Chart Outline"), + ProfileIcon("polyline", Icons.Filled.Polyline, "Polyline"), + ProfileIcon("post_add", Icons.Filled.PostAdd, "Post Add"), + ProfileIcon("publish", Icons.Filled.Publish, "Publish"), + ProfileIcon("query_stats", Icons.Filled.QueryStats, "Query Stats"), + ProfileIcon("rectangle", Icons.Filled.Rectangle, "Rectangle"), + ProfileIcon("scatter_plot", Icons.Filled.ScatterPlot, "Scatter Plot"), + ProfileIcon("schema", Icons.Filled.Schema, "Schema"), + ProfileIcon("score", Icons.Filled.Score, "Score"), + ProfileIcon("short_text", Icons.AutoMirrored.Filled.ShortText, "Short Text"), + ProfileIcon("show_chart", Icons.AutoMirrored.Filled.ShowChart, "Show Chart"), + ProfileIcon("space_bar", Icons.Filled.SpaceBar, "Space Bar"), + ProfileIcon("square", Icons.Filled.Square, "Square"), + ProfileIcon("stacked_line_chart", Icons.Filled.StackedLineChart, "Stacked Line Chart"), + ProfileIcon("strikethrough_s", Icons.Filled.StrikethroughS, "Strikethrough S"), + ProfileIcon("subscript", Icons.Filled.Subscript, "Subscript"), + ProfileIcon("superscript", Icons.Filled.Superscript, "Superscript"), + ProfileIcon("table_chart", Icons.Filled.TableChart, "Table Chart"), + ProfileIcon("table_rows", Icons.Filled.TableRows, "Table Rows"), + ProfileIcon("text_decrease", Icons.Filled.TextDecrease, "Text Decrease"), + ProfileIcon("text_fields", Icons.Filled.TextFields, "Text Fields"), + ProfileIcon("text_increase", Icons.Filled.TextIncrease, "Text Increase"), + ProfileIcon("title", Icons.Filled.Title, "Title"), + ProfileIcon("vertical_align_bottom", Icons.Filled.VerticalAlignBottom, "Vertical Bottom"), + ProfileIcon("vertical_align_center", Icons.Filled.VerticalAlignCenter, "Vertical Center"), + ProfileIcon("vertical_align_top", Icons.Filled.VerticalAlignTop, "Vertical Top"), + ProfileIcon("vertical_distribute", Icons.Filled.VerticalDistribute, "Vertical Distribute"), + ProfileIcon("waterfall_chart", Icons.Filled.WaterfallChart, "Waterfall Chart"), + ProfileIcon("wrap_text", Icons.AutoMirrored.Filled.WrapText, "Wrap Text"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt new file mode 100644 index 0000000000..df842a5969 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/FileIcons.kt @@ -0,0 +1,112 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Approval +import androidx.compose.material.icons.filled.AttachEmail +import androidx.compose.material.icons.filled.Attachment +import androidx.compose.material.icons.filled.Cloud +import androidx.compose.material.icons.filled.CloudCircle +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.CloudQueue +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.CloudUpload +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Difference +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.DownloadDone +import androidx.compose.material.icons.filled.DownloadForOffline +import androidx.compose.material.icons.filled.Downloading +import androidx.compose.material.icons.filled.DriveFileMove +import androidx.compose.material.icons.filled.DriveFileMoveRtl +import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.DriveFolderUpload +import androidx.compose.material.icons.filled.FileCopy +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileDownloadDone +import androidx.compose.material.icons.filled.FileDownloadOff +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.filled.FilePresent +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.FolderCopy +import androidx.compose.material.icons.filled.FolderDelete +import androidx.compose.material.icons.filled.FolderOff +import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.filled.FolderShared +import androidx.compose.material.icons.filled.FolderSpecial +import androidx.compose.material.icons.filled.FolderZip +import androidx.compose.material.icons.filled.FormatOverline +import androidx.compose.material.icons.filled.GridView +import androidx.compose.material.icons.filled.Javascript +import androidx.compose.material.icons.filled.Newspaper +import androidx.compose.material.icons.filled.RequestQuote +import androidx.compose.material.icons.filled.RuleFolder +import androidx.compose.material.icons.filled.SnippetFolder +import androidx.compose.material.icons.filled.Source +import androidx.compose.material.icons.filled.TextSnippet +import androidx.compose.material.icons.filled.Topic +import androidx.compose.material.icons.filled.Upload +import androidx.compose.material.icons.filled.UploadFile +import androidx.compose.material.icons.filled.Workspaces +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * File category icons - File types and operations + * Based on Google's Material Design Icons taxonomy + */ +object FileIcons { + val icons = + listOf( + ProfileIcon("approval", Icons.Filled.Approval, "Approval"), + ProfileIcon("attach_email", Icons.Filled.AttachEmail, "Attach Email"), + ProfileIcon("attachment", Icons.Filled.Attachment, "Attachment"), + ProfileIcon("cloud", Icons.Filled.Cloud, "Cloud"), + ProfileIcon("cloud_circle", Icons.Filled.CloudCircle, "Cloud Circle"), + ProfileIcon("cloud_done", Icons.Filled.CloudDone, "Cloud Done"), + ProfileIcon("cloud_download", Icons.Filled.CloudDownload, "Cloud Download"), + ProfileIcon("cloud_off", Icons.Filled.CloudOff, "Cloud Off"), + ProfileIcon("cloud_queue", Icons.Filled.CloudQueue, "Cloud Queue"), + ProfileIcon("cloud_sync", Icons.Filled.CloudSync, "Cloud Sync"), + ProfileIcon("cloud_upload", Icons.Filled.CloudUpload, "Cloud Upload"), + ProfileIcon("create_new_folder", Icons.Filled.CreateNewFolder, "New Folder"), + ProfileIcon("difference", Icons.Filled.Difference, "Difference"), + ProfileIcon("download", Icons.Filled.Download, "Download"), + ProfileIcon("download_done", Icons.Filled.DownloadDone, "Download Done"), + ProfileIcon("download_for_offline", Icons.Filled.DownloadForOffline, "Download Offline"), + ProfileIcon("downloading", Icons.Filled.Downloading, "Downloading"), + ProfileIcon("drive_file_move", Icons.Filled.DriveFileMove, "File Move"), + ProfileIcon("drive_file_move_rtl", Icons.Filled.DriveFileMoveRtl, "File Move RTL"), + ProfileIcon("drive_file_rename_outline", Icons.Filled.DriveFileRenameOutline, "Rename"), + ProfileIcon("drive_folder_upload", Icons.Filled.DriveFolderUpload, "Folder Upload"), + ProfileIcon("file_copy", Icons.Filled.FileCopy, "File Copy"), + ProfileIcon("file_download", Icons.Filled.FileDownload, "File Download"), + ProfileIcon("file_download_done", Icons.Filled.FileDownloadDone, "Download Done"), + ProfileIcon("file_download_off", Icons.Filled.FileDownloadOff, "Download Off"), + ProfileIcon("file_open", Icons.Filled.FileOpen, "File Open"), + ProfileIcon("file_present", Icons.Filled.FilePresent, "File Present"), + ProfileIcon("file_upload", Icons.Filled.FileUpload, "File Upload"), + ProfileIcon("folder", Icons.Filled.Folder, "Folder"), + ProfileIcon("folder_copy", Icons.Filled.FolderCopy, "Folder Copy"), + ProfileIcon("folder_delete", Icons.Filled.FolderDelete, "Folder Delete"), + ProfileIcon("folder_off", Icons.Filled.FolderOff, "Folder Off"), + ProfileIcon("folder_open", Icons.Filled.FolderOpen, "Folder Open"), + ProfileIcon("folder_shared", Icons.Filled.FolderShared, "Folder Shared"), + ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Folder Special"), + ProfileIcon("folder_zip", Icons.Filled.FolderZip, "Folder Zip"), + ProfileIcon("format_overline", Icons.Filled.FormatOverline, "Format Overline"), + ProfileIcon("grid_view", Icons.Filled.GridView, "Grid View"), + ProfileIcon("javascript", Icons.Filled.Javascript, "JavaScript"), + ProfileIcon("newspaper", Icons.Filled.Newspaper, "Newspaper"), + ProfileIcon("request_quote", Icons.Filled.RequestQuote, "Request Quote"), + ProfileIcon("rule_folder", Icons.Filled.RuleFolder, "Rule Folder"), + ProfileIcon("snippet_folder", Icons.Filled.SnippetFolder, "Snippet Folder"), + ProfileIcon("source", Icons.Filled.Source, "Source"), + ProfileIcon("text_snippet", Icons.Filled.TextSnippet, "Text Snippet"), + ProfileIcon("topic", Icons.Filled.Topic, "Topic"), + ProfileIcon("upload", Icons.Filled.Upload, "Upload"), + ProfileIcon("upload_file", Icons.Filled.UploadFile, "Upload File"), + ProfileIcon("workspaces", Icons.Filled.Workspaces, "Workspaces"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt new file mode 100644 index 0000000000..3486f616ca --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/HardwareIcons.kt @@ -0,0 +1,186 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.BrowserNotSupported +import androidx.compose.material.icons.filled.BrowserUpdated +import androidx.compose.material.icons.filled.Cast +import androidx.compose.material.icons.filled.CastConnected +import androidx.compose.material.icons.filled.CastForEducation +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.ConnectedTv +import androidx.compose.material.icons.filled.DesktopMac +import androidx.compose.material.icons.filled.DesktopWindows +import androidx.compose.material.icons.filled.DeveloperBoard +import androidx.compose.material.icons.filled.DeveloperBoardOff +import androidx.compose.material.icons.filled.DeviceHub +import androidx.compose.material.icons.filled.DeviceUnknown +import androidx.compose.material.icons.filled.DevicesOther +import androidx.compose.material.icons.filled.DisplaySettings +import androidx.compose.material.icons.filled.Dock +import androidx.compose.material.icons.filled.Earbuds +import androidx.compose.material.icons.filled.EarbudsBattery +import androidx.compose.material.icons.filled.Gamepad +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.HeadphonesBattery +import androidx.compose.material.icons.filled.Headset +import androidx.compose.material.icons.filled.HeadsetMic +import androidx.compose.material.icons.filled.HeadsetOff +import androidx.compose.material.icons.filled.HomeMax +import androidx.compose.material.icons.filled.HomeMini +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.material.icons.filled.KeyboardAlt +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.KeyboardBackspace +import androidx.compose.material.icons.filled.KeyboardCapslock +import androidx.compose.material.icons.filled.KeyboardCommandKey +import androidx.compose.material.icons.filled.KeyboardControlKey +import androidx.compose.material.icons.filled.KeyboardDoubleArrowDown +import androidx.compose.material.icons.filled.KeyboardDoubleArrowLeft +import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight +import androidx.compose.material.icons.filled.KeyboardDoubleArrowUp +import androidx.compose.material.icons.filled.KeyboardHide +import androidx.compose.material.icons.filled.KeyboardOptionKey +import androidx.compose.material.icons.filled.KeyboardReturn +import androidx.compose.material.icons.filled.KeyboardTab +import androidx.compose.material.icons.filled.KeyboardVoice +import androidx.compose.material.icons.filled.Laptop +import androidx.compose.material.icons.filled.LaptopChromebook +import androidx.compose.material.icons.filled.LaptopMac +import androidx.compose.material.icons.filled.LaptopWindows +import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Monitor +import androidx.compose.material.icons.filled.Mouse +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.PhoneIphone +import androidx.compose.material.icons.filled.Phonelink +import androidx.compose.material.icons.filled.PhonelinkOff +import androidx.compose.material.icons.filled.PivotTableChart +import androidx.compose.material.icons.filled.PointOfSale +import androidx.compose.material.icons.filled.PowerInput +import androidx.compose.material.icons.filled.Print +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Scanner +import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.SimCard +import androidx.compose.material.icons.filled.Smartphone +import androidx.compose.material.icons.filled.Speaker +import androidx.compose.material.icons.filled.SpeakerGroup +import androidx.compose.material.icons.filled.Start +import androidx.compose.material.icons.filled.Tablet +import androidx.compose.material.icons.filled.TabletAndroid +import androidx.compose.material.icons.filled.TabletMac +import androidx.compose.material.icons.filled.Toys +import androidx.compose.material.icons.filled.Tv +import androidx.compose.material.icons.filled.TvOff +import androidx.compose.material.icons.filled.VideogameAsset +import androidx.compose.material.icons.filled.VideogameAssetOff +import androidx.compose.material.icons.filled.Watch +import androidx.compose.material.icons.filled.WatchOff +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Hardware category icons - Physical hardware and peripherals + * Based on Google's Material Design Icons taxonomy + */ +object HardwareIcons { + val icons = + listOf( + ProfileIcon( + "browser_not_supported", + Icons.Filled.BrowserNotSupported, + "Browser Not Supported", + ), + ProfileIcon("browser_updated", Icons.Filled.BrowserUpdated, "Browser Updated"), + ProfileIcon("cast", Icons.Filled.Cast, "Cast"), + ProfileIcon("cast_connected", Icons.Filled.CastConnected, "Cast Connected"), + ProfileIcon("cast_for_education", Icons.Filled.CastForEducation, "Cast Education"), + ProfileIcon("computer", Icons.Filled.Computer, "Computer"), + ProfileIcon("connected_tv", Icons.Filled.ConnectedTv, "Connected TV"), + ProfileIcon("desktop_mac", Icons.Filled.DesktopMac, "Desktop Mac"), + ProfileIcon("desktop_windows", Icons.Filled.DesktopWindows, "Desktop Windows"), + ProfileIcon("developer_board", Icons.Filled.DeveloperBoard, "Developer Board"), + ProfileIcon("developer_board_off", Icons.Filled.DeveloperBoardOff, "Developer Board Off"), + ProfileIcon("device_hub", Icons.Filled.DeviceHub, "Device Hub"), + ProfileIcon("device_unknown", Icons.Filled.DeviceUnknown, "Device Unknown"), + ProfileIcon("devices_other", Icons.Filled.DevicesOther, "Devices Other"), + ProfileIcon("display_settings", Icons.Filled.DisplaySettings, "Display Settings"), + ProfileIcon("dock", Icons.Filled.Dock, "Dock"), + ProfileIcon("earbuds", Icons.Filled.Earbuds, "Earbuds"), + ProfileIcon("earbuds_battery", Icons.Filled.EarbudsBattery, "Earbuds Battery"), + ProfileIcon("gamepad", Icons.Filled.Gamepad, "Gamepad"), + ProfileIcon("headphones", Icons.Filled.Headphones, "Headphones"), + ProfileIcon("headphones_battery", Icons.Filled.HeadphonesBattery, "Headphones Battery"), + ProfileIcon("headset", Icons.Filled.Headset, "Headset"), + ProfileIcon("headset_mic", Icons.Filled.HeadsetMic, "Headset Mic"), + ProfileIcon("headset_off", Icons.Filled.HeadsetOff, "Headset Off"), + ProfileIcon("home_max", Icons.Filled.HomeMax, "Home Max"), + ProfileIcon("home_mini", Icons.Filled.HomeMini, "Home Mini"), + ProfileIcon("keyboard", Icons.Filled.Keyboard, "Keyboard"), + ProfileIcon("keyboard_alt", Icons.Filled.KeyboardAlt, "Keyboard Alt"), + ProfileIcon("keyboard_arrow_down", Icons.Filled.KeyboardArrowDown, "Arrow Down"), + ProfileIcon("keyboard_arrow_left", Icons.Filled.KeyboardArrowLeft, "Arrow Left"), + ProfileIcon("keyboard_arrow_right", Icons.Filled.KeyboardArrowRight, "Arrow Right"), + ProfileIcon("keyboard_arrow_up", Icons.Filled.KeyboardArrowUp, "Arrow Up"), + ProfileIcon("keyboard_backspace", Icons.Filled.KeyboardBackspace, "Backspace"), + ProfileIcon("keyboard_capslock", Icons.Filled.KeyboardCapslock, "Caps Lock"), + ProfileIcon("keyboard_command_key", Icons.Filled.KeyboardCommandKey, "Command Key"), + ProfileIcon("keyboard_control_key", Icons.Filled.KeyboardControlKey, "Control Key"), + ProfileIcon( + "keyboard_double_arrow_down", + Icons.Filled.KeyboardDoubleArrowDown, + "Double Down", + ), + ProfileIcon( + "keyboard_double_arrow_left", + Icons.Filled.KeyboardDoubleArrowLeft, + "Double Left", + ), + ProfileIcon( + "keyboard_double_arrow_right", + Icons.Filled.KeyboardDoubleArrowRight, + "Double Right", + ), + ProfileIcon("keyboard_double_arrow_up", Icons.Filled.KeyboardDoubleArrowUp, "Double Up"), + ProfileIcon("keyboard_hide", Icons.Filled.KeyboardHide, "Keyboard Hide"), + ProfileIcon("keyboard_option_key", Icons.Filled.KeyboardOptionKey, "Option Key"), + ProfileIcon("keyboard_return", Icons.Filled.KeyboardReturn, "Return"), + ProfileIcon("keyboard_tab", Icons.Filled.KeyboardTab, "Tab"), + ProfileIcon("keyboard_voice", Icons.Filled.KeyboardVoice, "Voice"), + ProfileIcon("laptop", Icons.Filled.Laptop, "Laptop"), + ProfileIcon("laptop_chromebook", Icons.Filled.LaptopChromebook, "Chromebook"), + ProfileIcon("laptop_mac", Icons.Filled.LaptopMac, "Laptop Mac"), + ProfileIcon("laptop_windows", Icons.Filled.LaptopWindows, "Laptop Windows"), + ProfileIcon("memory", Icons.Filled.Memory, "Memory"), + ProfileIcon("monitor", Icons.Filled.Monitor, "Monitor"), + ProfileIcon("mouse", Icons.Filled.Mouse, "Mouse"), + ProfileIcon("phone_android", Icons.Filled.PhoneAndroid, "Phone Android"), + ProfileIcon("phone_iphone", Icons.Filled.PhoneIphone, "Phone iPhone"), + ProfileIcon("phonelink", Icons.Filled.Phonelink, "Phonelink"), + ProfileIcon("phonelink_off", Icons.Filled.PhonelinkOff, "Phonelink Off"), + ProfileIcon("pivot_table_chart", Icons.Filled.PivotTableChart, "Pivot Table"), + ProfileIcon("point_of_sale", Icons.Filled.PointOfSale, "Point of Sale"), + ProfileIcon("power_input", Icons.Filled.PowerInput, "Power Input"), + ProfileIcon("printer", Icons.Filled.Print, "Printer"), + ProfileIcon("router", Icons.Filled.Router, "Router"), + ProfileIcon("scanner", Icons.Filled.Scanner, "Scanner"), + ProfileIcon("security", Icons.Filled.Security, "Security"), + ProfileIcon("sim_card", Icons.Filled.SimCard, "SIM Card"), + ProfileIcon("smartphone", Icons.Filled.Smartphone, "Smartphone"), + ProfileIcon("speaker", Icons.Filled.Speaker, "Speaker"), + ProfileIcon("speaker_group", Icons.Filled.SpeakerGroup, "Speaker Group"), + ProfileIcon("start", Icons.Filled.Start, "Start"), + ProfileIcon("tablet", Icons.Filled.Tablet, "Tablet"), + ProfileIcon("tablet_android", Icons.Filled.TabletAndroid, "Tablet Android"), + ProfileIcon("tablet_mac", Icons.Filled.TabletMac, "Tablet Mac"), + ProfileIcon("toys", Icons.Filled.Toys, "Toys"), + ProfileIcon("tv", Icons.Filled.Tv, "TV"), + ProfileIcon("tv_off", Icons.Filled.TvOff, "TV Off"), + ProfileIcon("videogame_asset", Icons.Filled.VideogameAsset, "Videogame"), + ProfileIcon("videogame_asset_off", Icons.Filled.VideogameAssetOff, "Videogame Off"), + ProfileIcon("watch", Icons.Filled.Watch, "Watch"), + ProfileIcon("watch_off", Icons.Filled.WatchOff, "Watch Off"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt new file mode 100644 index 0000000000..040e6305c5 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/IconCategory.kt @@ -0,0 +1,10 @@ +package io.nekohasekai.sfa.compose.util.icons + +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Represents a category of Material Icons following Google's official taxonomy + */ +data class IconCategory(val name: String, val icons: List) { + val size: Int get() = icons.size +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt new file mode 100644 index 0000000000..67ade2e06a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ImageIcons.kt @@ -0,0 +1,509 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ReceiptLong +import androidx.compose.material.icons.automirrored.filled.RotateLeft +import androidx.compose.material.icons.automirrored.filled.RotateRight +import androidx.compose.material.icons.filled.AddAPhoto +import androidx.compose.material.icons.filled.AddPhotoAlternate +import androidx.compose.material.icons.filled.AddToPhotos +import androidx.compose.material.icons.filled.Adjust +import androidx.compose.material.icons.filled.Animation +import androidx.compose.material.icons.filled.Assistant +import androidx.compose.material.icons.filled.AssistantPhoto +import androidx.compose.material.icons.filled.Audiotrack +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.AutoAwesomeMosaic +import androidx.compose.material.icons.filled.AutoAwesomeMotion +import androidx.compose.material.icons.filled.AutoFixHigh +import androidx.compose.material.icons.filled.AutoFixNormal +import androidx.compose.material.icons.filled.AutoFixOff +import androidx.compose.material.icons.filled.AutoMode +import androidx.compose.material.icons.filled.AutoStories +import androidx.compose.material.icons.filled.AutofpsSelect +import androidx.compose.material.icons.filled.Bedtime +import androidx.compose.material.icons.filled.BedtimeOff +import androidx.compose.material.icons.filled.BlurCircular +import androidx.compose.material.icons.filled.BlurLinear +import androidx.compose.material.icons.filled.BlurOff +import androidx.compose.material.icons.filled.BlurOn +import androidx.compose.material.icons.filled.Brightness1 +import androidx.compose.material.icons.filled.Brightness2 +import androidx.compose.material.icons.filled.Brightness3 +import androidx.compose.material.icons.filled.Brightness4 +import androidx.compose.material.icons.filled.Brightness5 +import androidx.compose.material.icons.filled.Brightness6 +import androidx.compose.material.icons.filled.Brightness7 +import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.filled.Brush +import androidx.compose.material.icons.filled.BurstMode +import androidx.compose.material.icons.filled.Camera +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.filled.CameraFront +import androidx.compose.material.icons.filled.CameraOutdoor +import androidx.compose.material.icons.filled.CameraRear +import androidx.compose.material.icons.filled.CameraRoll +import androidx.compose.material.icons.filled.Cases +import androidx.compose.material.icons.filled.CenterFocusStrong +import androidx.compose.material.icons.filled.CenterFocusWeak +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Collections +import androidx.compose.material.icons.filled.CollectionsBookmark +import androidx.compose.material.icons.filled.ColorLens +import androidx.compose.material.icons.filled.Colorize +import androidx.compose.material.icons.filled.Compare +import androidx.compose.material.icons.filled.Contrast +import androidx.compose.material.icons.filled.ControlPoint +import androidx.compose.material.icons.filled.ControlPointDuplicate +import androidx.compose.material.icons.filled.Crop +import androidx.compose.material.icons.filled.Crop169 +import androidx.compose.material.icons.filled.Crop32 +import androidx.compose.material.icons.filled.Crop54 +import androidx.compose.material.icons.filled.Crop75 +import androidx.compose.material.icons.filled.CropDin +import androidx.compose.material.icons.filled.CropFree +import androidx.compose.material.icons.filled.CropLandscape +import androidx.compose.material.icons.filled.CropOriginal +import androidx.compose.material.icons.filled.CropPortrait +import androidx.compose.material.icons.filled.CropRotate +import androidx.compose.material.icons.filled.CropSquare +import androidx.compose.material.icons.filled.CurrencyFranc +import androidx.compose.material.icons.filled.CurrencyLira +import androidx.compose.material.icons.filled.CurrencyPound +import androidx.compose.material.icons.filled.CurrencyRuble +import androidx.compose.material.icons.filled.CurrencyRupee +import androidx.compose.material.icons.filled.CurrencyYen +import androidx.compose.material.icons.filled.CurrencyYuan +import androidx.compose.material.icons.filled.Deblur +import androidx.compose.material.icons.filled.Dehaze +import androidx.compose.material.icons.filled.Details +import androidx.compose.material.icons.filled.DirtyLens +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Euro +import androidx.compose.material.icons.filled.Exposure +import androidx.compose.material.icons.filled.ExposureNeg1 +import androidx.compose.material.icons.filled.ExposureNeg2 +import androidx.compose.material.icons.filled.ExposurePlus1 +import androidx.compose.material.icons.filled.ExposurePlus2 +import androidx.compose.material.icons.filled.ExposureZero +import androidx.compose.material.icons.filled.FaceRetouchingNatural +import androidx.compose.material.icons.filled.FaceRetouchingOff +import androidx.compose.material.icons.filled.Filter +import androidx.compose.material.icons.filled.Filter1 +import androidx.compose.material.icons.filled.Filter2 +import androidx.compose.material.icons.filled.Filter3 +import androidx.compose.material.icons.filled.Filter4 +import androidx.compose.material.icons.filled.Filter5 +import androidx.compose.material.icons.filled.Filter6 +import androidx.compose.material.icons.filled.Filter7 +import androidx.compose.material.icons.filled.Filter8 +import androidx.compose.material.icons.filled.Filter9 +import androidx.compose.material.icons.filled.Filter9Plus +import androidx.compose.material.icons.filled.FilterBAndW +import androidx.compose.material.icons.filled.FilterCenterFocus +import androidx.compose.material.icons.filled.FilterDrama +import androidx.compose.material.icons.filled.FilterFrames +import androidx.compose.material.icons.filled.FilterHdr +import androidx.compose.material.icons.filled.FilterNone +import androidx.compose.material.icons.filled.FilterTiltShift +import androidx.compose.material.icons.filled.FilterVintage +import androidx.compose.material.icons.filled.Flare +import androidx.compose.material.icons.filled.FlashAuto +import androidx.compose.material.icons.filled.FlashOff +import androidx.compose.material.icons.filled.FlashOn +import androidx.compose.material.icons.filled.Flip +import androidx.compose.material.icons.filled.FlipCameraAndroid +import androidx.compose.material.icons.filled.FlipCameraIos +import androidx.compose.material.icons.filled.Gradient +import androidx.compose.material.icons.filled.Grain +import androidx.compose.material.icons.filled.GridOff +import androidx.compose.material.icons.filled.GridOn +import androidx.compose.material.icons.filled.HdrEnhancedSelect +import androidx.compose.material.icons.filled.HdrOff +import androidx.compose.material.icons.filled.HdrOn +import androidx.compose.material.icons.filled.HdrPlus +import androidx.compose.material.icons.filled.HdrStrong +import androidx.compose.material.icons.filled.HdrWeak +import androidx.compose.material.icons.filled.Healing +import androidx.compose.material.icons.filled.Hevc +import androidx.compose.material.icons.filled.HideImage +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.ImageAspectRatio +import androidx.compose.material.icons.filled.ImageNotSupported +import androidx.compose.material.icons.filled.ImageSearch +import androidx.compose.material.icons.filled.IncompleteCircle +import androidx.compose.material.icons.filled.Iso +import androidx.compose.material.icons.filled.Landscape +import androidx.compose.material.icons.filled.LeakAdd +import androidx.compose.material.icons.filled.LeakRemove +import androidx.compose.material.icons.filled.Lens +import androidx.compose.material.icons.filled.LinkedCamera +import androidx.compose.material.icons.filled.LogoDev +import androidx.compose.material.icons.filled.Looks +import androidx.compose.material.icons.filled.Looks3 +import androidx.compose.material.icons.filled.Looks4 +import androidx.compose.material.icons.filled.Looks5 +import androidx.compose.material.icons.filled.Looks6 +import androidx.compose.material.icons.filled.LooksOne +import androidx.compose.material.icons.filled.LooksTwo +import androidx.compose.material.icons.filled.Loupe +import androidx.compose.material.icons.filled.MicExternalOff +import androidx.compose.material.icons.filled.MicExternalOn +import androidx.compose.material.icons.filled.MonochromePhotos +import androidx.compose.material.icons.filled.MotionPhotosAuto +import androidx.compose.material.icons.filled.MotionPhotosOff +import androidx.compose.material.icons.filled.MotionPhotosOn +import androidx.compose.material.icons.filled.MotionPhotosPause +import androidx.compose.material.icons.filled.MotionPhotosPaused +import androidx.compose.material.icons.filled.MovieCreation +import androidx.compose.material.icons.filled.MovieFilter +import androidx.compose.material.icons.filled.Mp +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.MusicOff +import androidx.compose.material.icons.filled.Nature +import androidx.compose.material.icons.filled.NaturePeople +import androidx.compose.material.icons.filled.NavigateBefore +import androidx.compose.material.icons.filled.NavigateNext +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Panorama +import androidx.compose.material.icons.filled.PanoramaFishEye +import androidx.compose.material.icons.filled.PanoramaHorizontal +import androidx.compose.material.icons.filled.PanoramaHorizontalSelect +import androidx.compose.material.icons.filled.PanoramaPhotosphere +import androidx.compose.material.icons.filled.PanoramaPhotosphereSelect +import androidx.compose.material.icons.filled.PanoramaVertical +import androidx.compose.material.icons.filled.PanoramaVerticalSelect +import androidx.compose.material.icons.filled.PanoramaWideAngle +import androidx.compose.material.icons.filled.PanoramaWideAngleSelect +import androidx.compose.material.icons.filled.Photo +import androidx.compose.material.icons.filled.PhotoAlbum +import androidx.compose.material.icons.filled.PhotoCamera +import androidx.compose.material.icons.filled.PhotoCameraBack +import androidx.compose.material.icons.filled.PhotoCameraFront +import androidx.compose.material.icons.filled.PhotoFilter +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material.icons.filled.PhotoSizeSelectActual +import androidx.compose.material.icons.filled.PhotoSizeSelectLarge +import androidx.compose.material.icons.filled.PhotoSizeSelectSmall +import androidx.compose.material.icons.filled.PictureAsPdf +import androidx.compose.material.icons.filled.Portrait +import androidx.compose.material.icons.filled.RawOff +import androidx.compose.material.icons.filled.RawOn +import androidx.compose.material.icons.filled.RemoveRedEye +import androidx.compose.material.icons.filled.Rotate90DegreesCcw +import androidx.compose.material.icons.filled.Rotate90DegreesCw +import androidx.compose.material.icons.filled.ShutterSpeed +import androidx.compose.material.icons.filled.Slideshow +import androidx.compose.material.icons.filled.Straighten +import androidx.compose.material.icons.filled.Style +import androidx.compose.material.icons.filled.SwitchCamera +import androidx.compose.material.icons.filled.SwitchVideo +import androidx.compose.material.icons.filled.TagFaces +import androidx.compose.material.icons.filled.Texture +import androidx.compose.material.icons.filled.ThermostatAuto +import androidx.compose.material.icons.filled.Timelapse +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material.icons.filled.Timer10 +import androidx.compose.material.icons.filled.Timer3 +import androidx.compose.material.icons.filled.TimerOff +import androidx.compose.material.icons.filled.Tonality +import androidx.compose.material.icons.filled.Transform +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material.icons.filled.VideoCameraBack +import androidx.compose.material.icons.filled.VideoCameraFront +import androidx.compose.material.icons.filled.VideoStable +import androidx.compose.material.icons.filled.ViewComfy +import androidx.compose.material.icons.filled.ViewCompact +import androidx.compose.material.icons.filled.Vignette +import androidx.compose.material.icons.filled.Vrpano +import androidx.compose.material.icons.filled.WbAuto +import androidx.compose.material.icons.filled.WbCloudy +import androidx.compose.material.icons.filled.WbIncandescent +import androidx.compose.material.icons.filled.WbIridescent +import androidx.compose.material.icons.filled.WbShade +import androidx.compose.material.icons.filled.WbSunny +import androidx.compose.material.icons.filled.WbTwilight +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Image category icons - Image editing and gallery + * Based on Google's Material Design Icons taxonomy + */ +object ImageIcons { + val icons = + listOf( + // ProfileIcon("10mp", Icons.Filled.TenMp, "10MP"), + // ProfileIcon("11mp", Icons.Filled.ElevenMp, "11MP"), + // ProfileIcon("12mp", Icons.Filled.TwelveMp, "12MP"), + // ProfileIcon("13mp", Icons.Filled.ThirteenMp, "13MP"), + // ProfileIcon("14mp", Icons.Filled.FourteenMp, "14MP"), + // ProfileIcon("15mp", Icons.Filled.FifteenMp, "15MP"), + // ProfileIcon("16mp", Icons.Filled.SixteenMp, "16MP"), + // ProfileIcon("17mp", Icons.Filled.SeventeenMp, "17MP"), + // ProfileIcon("18mp", Icons.Filled.EighteenMp, "18MP"), + // ProfileIcon("19mp", Icons.Filled.NineteenMp, "19MP"), + // ProfileIcon("20mp", Icons.Filled.TwentyMp, "20MP"), + // ProfileIcon("21mp", Icons.Filled.TwentyOneMp, "21MP"), + // ProfileIcon("22mp", Icons.Filled.TwentyTwoMp, "22MP"), + // ProfileIcon("23mp", Icons.Filled.TwentyThreeMp, "23MP"), + // ProfileIcon("24mp", Icons.Filled.TwentyFourMp, "24MP"), + // ProfileIcon("2mp", Icons.Filled.TwoMp, "2MP"), + // ProfileIcon("30fps", Icons.Filled.ThirtyFps, "30 FPS"), // Not available + // ProfileIcon("30fps_select", Icons.Filled.ThirtyFpsSelect, "30 FPS Select"), + // ProfileIcon("3mp", Icons.Filled.ThreeMp, "3MP"), + // ProfileIcon("4mp", Icons.Filled.FourMp, "4MP"), + // ProfileIcon("5mp", Icons.Filled.FiveMp, "5MP"), + // ProfileIcon("60fps", Icons.Filled.SixtyFps, "60 FPS"), + // ProfileIcon("60fps_select", Icons.Filled.SixtyFpsSelect, "60 FPS Select"), + // ProfileIcon("6mp", Icons.Filled.SixMp, "6MP"), + // ProfileIcon("7mp", Icons.Filled.SevenMp, "7MP"), + // ProfileIcon("8mp", Icons.Filled.EightMp, "8MP"), + // ProfileIcon("9mp", Icons.Filled.NineMp, "9MP"), + ProfileIcon("add_a_photo", Icons.Filled.AddAPhoto, "Add Photo"), + ProfileIcon("add_photo_alternate", Icons.Filled.AddPhotoAlternate, "Add Photo Alt"), + ProfileIcon("add_to_photos", Icons.Filled.AddToPhotos, "Add to Photos"), + ProfileIcon("adjust", Icons.Filled.Adjust, "Adjust"), + ProfileIcon("animation", Icons.Filled.Animation, "Animation"), + ProfileIcon("assistant", Icons.Filled.Assistant, "Assistant"), + ProfileIcon("assistant_photo", Icons.Filled.AssistantPhoto, "Assistant Photo"), + ProfileIcon("audiotrack", Icons.Filled.Audiotrack, "Audio Track"), + ProfileIcon("auto_awesome", Icons.Filled.AutoAwesome, "Auto Awesome"), + ProfileIcon("auto_awesome_mosaic", Icons.Filled.AutoAwesomeMosaic, "Auto Mosaic"), + ProfileIcon("auto_awesome_motion", Icons.Filled.AutoAwesomeMotion, "Auto Motion"), + ProfileIcon("auto_fix_high", Icons.Filled.AutoFixHigh, "Auto Fix High"), + ProfileIcon("auto_fix_normal", Icons.Filled.AutoFixNormal, "Auto Fix Normal"), + ProfileIcon("auto_fix_off", Icons.Filled.AutoFixOff, "Auto Fix Off"), + ProfileIcon("auto_mode", Icons.Filled.AutoMode, "Auto Mode"), + ProfileIcon("auto_stories", Icons.Filled.AutoStories, "Auto Stories"), + ProfileIcon("autofps_select", Icons.Filled.AutofpsSelect, "Auto FPS Select"), + ProfileIcon("bedtime", Icons.Filled.Bedtime, "Bedtime"), + ProfileIcon("bedtime_off", Icons.Filled.BedtimeOff, "Bedtime Off"), + ProfileIcon("blur_circular", Icons.Filled.BlurCircular, "Blur Circular"), + ProfileIcon("blur_linear", Icons.Filled.BlurLinear, "Blur Linear"), + ProfileIcon("blur_off", Icons.Filled.BlurOff, "Blur Off"), + ProfileIcon("blur_on", Icons.Filled.BlurOn, "Blur On"), + ProfileIcon("brightness_1", Icons.Filled.Brightness1, "Brightness 1"), + ProfileIcon("brightness_2", Icons.Filled.Brightness2, "Brightness 2"), + ProfileIcon("brightness_3", Icons.Filled.Brightness3, "Brightness 3"), + ProfileIcon("brightness_4", Icons.Filled.Brightness4, "Brightness 4"), + ProfileIcon("brightness_5", Icons.Filled.Brightness5, "Brightness 5"), + ProfileIcon("brightness_6", Icons.Filled.Brightness6, "Brightness 6"), + ProfileIcon("brightness_7", Icons.Filled.Brightness7, "Brightness 7"), + ProfileIcon("broken_image", Icons.Filled.BrokenImage, "Broken Image"), + ProfileIcon("brush", Icons.Filled.Brush, "Brush"), + ProfileIcon("burst_mode", Icons.Filled.BurstMode, "Burst Mode"), + ProfileIcon("camera", Icons.Filled.Camera, "Camera"), + ProfileIcon("camera_alt", Icons.Filled.CameraAlt, "Camera Alt"), + ProfileIcon("camera_front", Icons.Filled.CameraFront, "Camera Front"), + ProfileIcon("camera_outdoor", Icons.Filled.CameraOutdoor, "Camera Outdoor"), + ProfileIcon("camera_rear", Icons.Filled.CameraRear, "Camera Rear"), + ProfileIcon("camera_roll", Icons.Filled.CameraRoll, "Camera Roll"), + ProfileIcon("cases", Icons.Filled.Cases, "Cases"), + ProfileIcon("center_focus_strong", Icons.Filled.CenterFocusStrong, "Center Focus Strong"), + ProfileIcon("center_focus_weak", Icons.Filled.CenterFocusWeak, "Center Focus Weak"), + ProfileIcon("circle", Icons.Filled.Circle, "Circle"), + ProfileIcon("collections", Icons.Filled.Collections, "Collections"), + ProfileIcon( + "collections_bookmark", + Icons.Filled.CollectionsBookmark, + "Collections Bookmark", + ), + ProfileIcon("color_lens", Icons.Filled.ColorLens, "Color Lens"), + ProfileIcon("colorize", Icons.Filled.Colorize, "Colorize"), + ProfileIcon("compare", Icons.Filled.Compare, "Compare"), + ProfileIcon("contrast", Icons.Filled.Contrast, "Contrast"), + ProfileIcon("control_point", Icons.Filled.ControlPoint, "Control Point"), + ProfileIcon( + "control_point_duplicate", + Icons.Filled.ControlPointDuplicate, + "Control Duplicate", + ), + ProfileIcon("crop", Icons.Filled.Crop, "Crop"), + ProfileIcon("crop_16_9", Icons.Filled.Crop169, "Crop 16:9"), + ProfileIcon("crop_3_2", Icons.Filled.Crop32, "Crop 3:2"), + ProfileIcon("crop_5_4", Icons.Filled.Crop54, "Crop 5:4"), + ProfileIcon("crop_7_5", Icons.Filled.Crop75, "Crop 7:5"), + ProfileIcon("crop_din", Icons.Filled.CropDin, "Crop Din"), + ProfileIcon("crop_free", Icons.Filled.CropFree, "Crop Free"), + ProfileIcon("crop_landscape", Icons.Filled.CropLandscape, "Crop Landscape"), + ProfileIcon("crop_original", Icons.Filled.CropOriginal, "Crop Original"), + ProfileIcon("crop_portrait", Icons.Filled.CropPortrait, "Crop Portrait"), + ProfileIcon("crop_rotate", Icons.Filled.CropRotate, "Crop Rotate"), + ProfileIcon("crop_square", Icons.Filled.CropSquare, "Crop Square"), + ProfileIcon("currency_franc", Icons.Filled.CurrencyFranc, "Currency Franc"), + ProfileIcon("currency_lira", Icons.Filled.CurrencyLira, "Currency Lira"), + ProfileIcon("currency_pound", Icons.Filled.CurrencyPound, "Currency Pound"), + ProfileIcon("currency_ruble", Icons.Filled.CurrencyRuble, "Currency Ruble"), + ProfileIcon("currency_rupee", Icons.Filled.CurrencyRupee, "Currency Rupee"), + ProfileIcon("currency_yen", Icons.Filled.CurrencyYen, "Currency Yen"), + ProfileIcon("currency_yuan", Icons.Filled.CurrencyYuan, "Currency Yuan"), + ProfileIcon("deblur", Icons.Filled.Deblur, "Deblur"), + ProfileIcon("dehaze", Icons.Filled.Dehaze, "Dehaze"), + ProfileIcon("details", Icons.Filled.Details, "Details"), + ProfileIcon("dirty_lens", Icons.Filled.DirtyLens, "Dirty Lens"), + ProfileIcon("edit", Icons.Filled.Edit, "Edit"), + ProfileIcon("euro", Icons.Filled.Euro, "Euro"), + ProfileIcon("exposure", Icons.Filled.Exposure, "Exposure"), + ProfileIcon("exposure_neg_1", Icons.Filled.ExposureNeg1, "Exposure -1"), + ProfileIcon("exposure_neg_2", Icons.Filled.ExposureNeg2, "Exposure -2"), + ProfileIcon("exposure_plus_1", Icons.Filled.ExposurePlus1, "Exposure +1"), + ProfileIcon("exposure_plus_2", Icons.Filled.ExposurePlus2, "Exposure +2"), + ProfileIcon("exposure_zero", Icons.Filled.ExposureZero, "Exposure 0"), + ProfileIcon("face_retouching_natural", Icons.Filled.FaceRetouchingNatural, "Face Natural"), + ProfileIcon("face_retouching_off", Icons.Filled.FaceRetouchingOff, "Face Off"), + ProfileIcon("filter", Icons.Filled.Filter, "Filter"), + ProfileIcon("filter_1", Icons.Filled.Filter1, "Filter 1"), + ProfileIcon("filter_2", Icons.Filled.Filter2, "Filter 2"), + ProfileIcon("filter_3", Icons.Filled.Filter3, "Filter 3"), + ProfileIcon("filter_4", Icons.Filled.Filter4, "Filter 4"), + ProfileIcon("filter_5", Icons.Filled.Filter5, "Filter 5"), + ProfileIcon("filter_6", Icons.Filled.Filter6, "Filter 6"), + ProfileIcon("filter_7", Icons.Filled.Filter7, "Filter 7"), + ProfileIcon("filter_8", Icons.Filled.Filter8, "Filter 8"), + ProfileIcon("filter_9", Icons.Filled.Filter9, "Filter 9"), + ProfileIcon("filter_9_plus", Icons.Filled.Filter9Plus, "Filter 9+"), + ProfileIcon("filter_b_and_w", Icons.Filled.FilterBAndW, "Filter B&W"), + ProfileIcon("filter_center_focus", Icons.Filled.FilterCenterFocus, "Filter Focus"), + ProfileIcon("filter_drama", Icons.Filled.FilterDrama, "Filter Drama"), + ProfileIcon("filter_frames", Icons.Filled.FilterFrames, "Filter Frames"), + ProfileIcon("filter_hdr", Icons.Filled.FilterHdr, "Filter HDR"), + ProfileIcon("filter_none", Icons.Filled.FilterNone, "Filter None"), + ProfileIcon("filter_tilt_shift", Icons.Filled.FilterTiltShift, "Filter Tilt"), + ProfileIcon("filter_vintage", Icons.Filled.FilterVintage, "Filter Vintage"), + ProfileIcon("flare", Icons.Filled.Flare, "Flare"), + ProfileIcon("flash_auto", Icons.Filled.FlashAuto, "Flash Auto"), + ProfileIcon("flash_off", Icons.Filled.FlashOff, "Flash Off"), + ProfileIcon("flash_on", Icons.Filled.FlashOn, "Flash On"), + ProfileIcon("flip", Icons.Filled.Flip, "Flip"), + ProfileIcon("flip_camera_android", Icons.Filled.FlipCameraAndroid, "Flip Camera"), + ProfileIcon("flip_camera_ios", Icons.Filled.FlipCameraIos, "Flip Camera iOS"), + ProfileIcon("gradient", Icons.Filled.Gradient, "Gradient"), + ProfileIcon("grain", Icons.Filled.Grain, "Grain"), + ProfileIcon("grid_off", Icons.Filled.GridOff, "Grid Off"), + ProfileIcon("grid_on", Icons.Filled.GridOn, "Grid On"), + ProfileIcon("hdr_enhanced_select", Icons.Filled.HdrEnhancedSelect, "HDR Enhanced"), + ProfileIcon("hdr_off", Icons.Filled.HdrOff, "HDR Off"), + ProfileIcon("hdr_on", Icons.Filled.HdrOn, "HDR On"), + ProfileIcon("hdr_plus", Icons.Filled.HdrPlus, "HDR Plus"), + ProfileIcon("hdr_strong", Icons.Filled.HdrStrong, "HDR Strong"), + ProfileIcon("hdr_weak", Icons.Filled.HdrWeak, "HDR Weak"), + ProfileIcon("healing", Icons.Filled.Healing, "Healing"), + ProfileIcon("hevc", Icons.Filled.Hevc, "HEVC"), + ProfileIcon("hide_image", Icons.Filled.HideImage, "Hide Image"), + ProfileIcon("image", Icons.Filled.Image, "Image"), + ProfileIcon("image_aspect_ratio", Icons.Filled.ImageAspectRatio, "Image Aspect"), + ProfileIcon("image_not_supported", Icons.Filled.ImageNotSupported, "Image Not Supported"), + ProfileIcon("image_search", Icons.Filled.ImageSearch, "Image Search"), + ProfileIcon("incomplete_circle", Icons.Filled.IncompleteCircle, "Incomplete Circle"), + ProfileIcon("iso", Icons.Filled.Iso, "ISO"), + ProfileIcon("landscape", Icons.Filled.Landscape, "Landscape"), + ProfileIcon("leak_add", Icons.Filled.LeakAdd, "Leak Add"), + ProfileIcon("leak_remove", Icons.Filled.LeakRemove, "Leak Remove"), + ProfileIcon("lens", Icons.Filled.Lens, "Lens"), + ProfileIcon("linked_camera", Icons.Filled.LinkedCamera, "Linked Camera"), + ProfileIcon("logo_dev", Icons.Filled.LogoDev, "Logo Dev"), + ProfileIcon("looks", Icons.Filled.Looks, "Looks"), + ProfileIcon("looks_3", Icons.Filled.Looks3, "Looks 3"), + ProfileIcon("looks_4", Icons.Filled.Looks4, "Looks 4"), + ProfileIcon("looks_5", Icons.Filled.Looks5, "Looks 5"), + ProfileIcon("looks_6", Icons.Filled.Looks6, "Looks 6"), + ProfileIcon("looks_one", Icons.Filled.LooksOne, "Looks One"), + ProfileIcon("looks_two", Icons.Filled.LooksTwo, "Looks Two"), + ProfileIcon("loupe", Icons.Filled.Loupe, "Loupe"), + ProfileIcon("mic_external_off", Icons.Filled.MicExternalOff, "Mic External Off"), + ProfileIcon("mic_external_on", Icons.Filled.MicExternalOn, "Mic External On"), + ProfileIcon("monochrome_photos", Icons.Filled.MonochromePhotos, "Monochrome"), + ProfileIcon("motion_photos_auto", Icons.Filled.MotionPhotosAuto, "Motion Auto"), + ProfileIcon("motion_photos_off", Icons.Filled.MotionPhotosOff, "Motion Off"), + ProfileIcon("motion_photos_on", Icons.Filled.MotionPhotosOn, "Motion On"), + ProfileIcon("motion_photos_pause", Icons.Filled.MotionPhotosPause, "Motion Pause"), + ProfileIcon("motion_photos_paused", Icons.Filled.MotionPhotosPaused, "Motion Paused"), + ProfileIcon("movie_creation", Icons.Filled.MovieCreation, "Movie Creation"), + ProfileIcon("movie_filter", Icons.Filled.MovieFilter, "Movie Filter"), + ProfileIcon("mp", Icons.Filled.Mp, "MP"), + ProfileIcon("music_note", Icons.Filled.MusicNote, "Music Note"), + ProfileIcon("music_off", Icons.Filled.MusicOff, "Music Off"), + ProfileIcon("nature", Icons.Filled.Nature, "Nature"), + ProfileIcon("nature_people", Icons.Filled.NaturePeople, "Nature People"), + ProfileIcon("navigate_before", Icons.Filled.NavigateBefore, "Navigate Before"), + ProfileIcon("navigate_next", Icons.Filled.NavigateNext, "Navigate Next"), + ProfileIcon("palette", Icons.Filled.Palette, "Palette"), + ProfileIcon("panorama", Icons.Filled.Panorama, "Panorama"), + ProfileIcon("panorama_fish_eye", Icons.Filled.PanoramaFishEye, "Fish Eye"), + ProfileIcon("panorama_horizontal", Icons.Filled.PanoramaHorizontal, "Panorama Horizontal"), + ProfileIcon( + "panorama_horizontal_select", + Icons.Filled.PanoramaHorizontalSelect, + "Horizontal Select", + ), + ProfileIcon("panorama_photosphere", Icons.Filled.PanoramaPhotosphere, "Photosphere"), + ProfileIcon( + "panorama_photosphere_select", + Icons.Filled.PanoramaPhotosphereSelect, + "Photosphere Select", + ), + ProfileIcon("panorama_vertical", Icons.Filled.PanoramaVertical, "Panorama Vertical"), + ProfileIcon( + "panorama_vertical_select", + Icons.Filled.PanoramaVerticalSelect, + "Vertical Select", + ), + ProfileIcon("panorama_wide_angle", Icons.Filled.PanoramaWideAngle, "Wide Angle"), + ProfileIcon( + "panorama_wide_angle_select", + Icons.Filled.PanoramaWideAngleSelect, + "Wide Select", + ), + ProfileIcon("photo", Icons.Filled.Photo, "Photo"), + ProfileIcon("photo_album", Icons.Filled.PhotoAlbum, "Photo Album"), + ProfileIcon("photo_camera", Icons.Filled.PhotoCamera, "Photo Camera"), + ProfileIcon("photo_camera_back", Icons.Filled.PhotoCameraBack, "Camera Back"), + ProfileIcon("photo_camera_front", Icons.Filled.PhotoCameraFront, "Camera Front"), + ProfileIcon("photo_filter", Icons.Filled.PhotoFilter, "Photo Filter"), + ProfileIcon("photo_library", Icons.Filled.PhotoLibrary, "Photo Library"), + ProfileIcon("photo_size_select_actual", Icons.Filled.PhotoSizeSelectActual, "Actual Size"), + ProfileIcon("photo_size_select_large", Icons.Filled.PhotoSizeSelectLarge, "Large Size"), + ProfileIcon("photo_size_select_small", Icons.Filled.PhotoSizeSelectSmall, "Small Size"), + ProfileIcon("picture_as_pdf", Icons.Filled.PictureAsPdf, "Picture as PDF"), + ProfileIcon("portrait", Icons.Filled.Portrait, "Portrait"), + ProfileIcon("raw_off", Icons.Filled.RawOff, "RAW Off"), + ProfileIcon("raw_on", Icons.Filled.RawOn, "RAW On"), + ProfileIcon("receipt_long", Icons.AutoMirrored.Filled.ReceiptLong, "Receipt Long"), + ProfileIcon("remove_red_eye", Icons.Filled.RemoveRedEye, "Remove Red Eye"), + ProfileIcon("rotate_90_degrees_ccw", Icons.Filled.Rotate90DegreesCcw, "Rotate CCW"), + ProfileIcon("rotate_90_degrees_cw", Icons.Filled.Rotate90DegreesCw, "Rotate CW"), + ProfileIcon("rotate_left", Icons.AutoMirrored.Filled.RotateLeft, "Rotate Left"), + ProfileIcon("rotate_right", Icons.AutoMirrored.Filled.RotateRight, "Rotate Right"), + ProfileIcon("shutter_speed", Icons.Filled.ShutterSpeed, "Shutter Speed"), + ProfileIcon("slideshow", Icons.Filled.Slideshow, "Slideshow"), + ProfileIcon("straighten", Icons.Filled.Straighten, "Straighten"), + ProfileIcon("style", Icons.Filled.Style, "Style"), + ProfileIcon("switch_camera", Icons.Filled.SwitchCamera, "Switch Camera"), + ProfileIcon("switch_video", Icons.Filled.SwitchVideo, "Switch Video"), + ProfileIcon("tag_faces", Icons.Filled.TagFaces, "Tag Faces"), + ProfileIcon("texture", Icons.Filled.Texture, "Texture"), + ProfileIcon("thermostat_auto", Icons.Filled.ThermostatAuto, "Thermostat Auto"), + ProfileIcon("timelapse", Icons.Filled.Timelapse, "Timelapse"), + ProfileIcon("timer", Icons.Filled.Timer, "Timer"), + ProfileIcon("timer_10", Icons.Filled.Timer10, "Timer 10"), + ProfileIcon("timer_3", Icons.Filled.Timer3, "Timer 3"), + ProfileIcon("timer_off", Icons.Filled.TimerOff, "Timer Off"), + ProfileIcon("tonality", Icons.Filled.Tonality, "Tonality"), + ProfileIcon("transform", Icons.Filled.Transform, "Transform"), + ProfileIcon("tune", Icons.Filled.Tune, "Tune"), + ProfileIcon("video_camera_back", Icons.Filled.VideoCameraBack, "Video Back"), + ProfileIcon("video_camera_front", Icons.Filled.VideoCameraFront, "Video Front"), + ProfileIcon("video_stable", Icons.Filled.VideoStable, "Video Stable"), + ProfileIcon("view_comfy", Icons.Filled.ViewComfy, "View Comfy"), + ProfileIcon("view_compact", Icons.Filled.ViewCompact, "View Compact"), + ProfileIcon("vignette", Icons.Filled.Vignette, "Vignette"), + ProfileIcon("vrpano", Icons.Filled.Vrpano, "VR Pano"), + ProfileIcon("wb_auto", Icons.Filled.WbAuto, "WB Auto"), + ProfileIcon("wb_cloudy", Icons.Filled.WbCloudy, "WB Cloudy"), + ProfileIcon("wb_incandescent", Icons.Filled.WbIncandescent, "WB Incandescent"), + ProfileIcon("wb_iridescent", Icons.Filled.WbIridescent, "WB Iridescent"), + ProfileIcon("wb_shade", Icons.Filled.WbShade, "WB Shade"), + ProfileIcon("wb_sunny", Icons.Filled.WbSunny, "WB Sunny"), + ProfileIcon("wb_twilight", Icons.Filled.WbTwilight, "WB Twilight"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt new file mode 100644 index 0000000000..6b246098f6 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MapsIcons.kt @@ -0,0 +1,465 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddLocation +import androidx.compose.material.icons.filled.AddLocationAlt +import androidx.compose.material.icons.filled.AddRoad +import androidx.compose.material.icons.filled.Agriculture +import androidx.compose.material.icons.filled.AirlineStops +import androidx.compose.material.icons.filled.Airlines +import androidx.compose.material.icons.filled.AltRoute +import androidx.compose.material.icons.filled.Atm +import androidx.compose.material.icons.filled.Attractions +import androidx.compose.material.icons.filled.Badge +import androidx.compose.material.icons.filled.BakeryDining +import androidx.compose.material.icons.filled.Beenhere +import androidx.compose.material.icons.filled.BikeScooter +import androidx.compose.material.icons.filled.BreakfastDining +import androidx.compose.material.icons.filled.BrunchDining +import androidx.compose.material.icons.filled.BusAlert +import androidx.compose.material.icons.filled.CarCrash +import androidx.compose.material.icons.filled.CarRental +import androidx.compose.material.icons.filled.CarRepair +import androidx.compose.material.icons.filled.Castle +import androidx.compose.material.icons.filled.Category +import androidx.compose.material.icons.filled.Celebration +import androidx.compose.material.icons.filled.Church +import androidx.compose.material.icons.filled.CleaningServices +import androidx.compose.material.icons.filled.CompassCalibration +import androidx.compose.material.icons.filled.ConnectingAirports +import androidx.compose.material.icons.filled.CrisisAlert +import androidx.compose.material.icons.filled.DeliveryDining +import androidx.compose.material.icons.filled.DepartureBoard +import androidx.compose.material.icons.filled.DesignServices +import androidx.compose.material.icons.filled.Diamond +import androidx.compose.material.icons.filled.DinnerDining +import androidx.compose.material.icons.filled.Directions +import androidx.compose.material.icons.filled.DirectionsBike +import androidx.compose.material.icons.filled.DirectionsBoat +import androidx.compose.material.icons.filled.DirectionsBoatFilled +import androidx.compose.material.icons.filled.DirectionsBus +import androidx.compose.material.icons.filled.DirectionsBusFilled +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.DirectionsCarFilled +import androidx.compose.material.icons.filled.DirectionsRailway +import androidx.compose.material.icons.filled.DirectionsRailwayFilled +import androidx.compose.material.icons.filled.DirectionsRun +import androidx.compose.material.icons.filled.DirectionsSubway +import androidx.compose.material.icons.filled.DirectionsSubwayFilled +import androidx.compose.material.icons.filled.DirectionsTransit +import androidx.compose.material.icons.filled.DirectionsTransitFilled +import androidx.compose.material.icons.filled.DirectionsWalk +import androidx.compose.material.icons.filled.DryCleaning +import androidx.compose.material.icons.filled.EditAttributes +import androidx.compose.material.icons.filled.EditLocation +import androidx.compose.material.icons.filled.EditLocationAlt +import androidx.compose.material.icons.filled.EditRoad +import androidx.compose.material.icons.filled.Egg +import androidx.compose.material.icons.filled.EggAlt +import androidx.compose.material.icons.filled.ElectricBike +import androidx.compose.material.icons.filled.ElectricCar +import androidx.compose.material.icons.filled.ElectricMoped +import androidx.compose.material.icons.filled.ElectricRickshaw +import androidx.compose.material.icons.filled.ElectricScooter +import androidx.compose.material.icons.filled.ElectricalServices +import androidx.compose.material.icons.filled.Emergency +import androidx.compose.material.icons.filled.EmergencyRecording +import androidx.compose.material.icons.filled.EmergencyShare +import androidx.compose.material.icons.filled.EvStation +import androidx.compose.material.icons.filled.Factory +import androidx.compose.material.icons.filled.Fastfood +import androidx.compose.material.icons.filled.Festival +import androidx.compose.material.icons.filled.FireExtinguisher +import androidx.compose.material.icons.filled.FireHydrantAlt +import androidx.compose.material.icons.filled.FireTruck +import androidx.compose.material.icons.filled.Flight +import androidx.compose.material.icons.filled.FlightClass +import androidx.compose.material.icons.filled.FlightLand +import androidx.compose.material.icons.filled.FlightTakeoff +import androidx.compose.material.icons.filled.FoodBank +import androidx.compose.material.icons.filled.Forest +import androidx.compose.material.icons.filled.ForkLeft +import androidx.compose.material.icons.filled.ForkRight +import androidx.compose.material.icons.filled.Fort +import androidx.compose.material.icons.filled.Hail +import androidx.compose.material.icons.filled.Handyman +import androidx.compose.material.icons.filled.Hardware +import androidx.compose.material.icons.filled.HomeRepairService +import androidx.compose.material.icons.filled.Hotel +import androidx.compose.material.icons.filled.Hvac +import androidx.compose.material.icons.filled.Icecream +import androidx.compose.material.icons.filled.KebabDining +import androidx.compose.material.icons.filled.Layers +import androidx.compose.material.icons.filled.LayersClear +import androidx.compose.material.icons.filled.Liquor +import androidx.compose.material.icons.filled.LocalActivity +import androidx.compose.material.icons.filled.LocalAirport +import androidx.compose.material.icons.filled.LocalAtm +import androidx.compose.material.icons.filled.LocalBar +import androidx.compose.material.icons.filled.LocalCafe +import androidx.compose.material.icons.filled.LocalCarWash +import androidx.compose.material.icons.filled.LocalConvenienceStore +import androidx.compose.material.icons.filled.LocalDining +import androidx.compose.material.icons.filled.LocalDrink +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.LocalFlorist +import androidx.compose.material.icons.filled.LocalGasStation +import androidx.compose.material.icons.filled.LocalGroceryStore +import androidx.compose.material.icons.filled.LocalHospital +import androidx.compose.material.icons.filled.LocalHotel +import androidx.compose.material.icons.filled.LocalLaundryService +import androidx.compose.material.icons.filled.LocalLibrary +import androidx.compose.material.icons.filled.LocalMall +import androidx.compose.material.icons.filled.LocalMovies +import androidx.compose.material.icons.filled.LocalOffer +import androidx.compose.material.icons.filled.LocalParking +import androidx.compose.material.icons.filled.LocalPharmacy +import androidx.compose.material.icons.filled.LocalPhone +import androidx.compose.material.icons.filled.LocalPizza +import androidx.compose.material.icons.filled.LocalPlay +import androidx.compose.material.icons.filled.LocalPolice +import androidx.compose.material.icons.filled.LocalPostOffice +import androidx.compose.material.icons.filled.LocalPrintshop +import androidx.compose.material.icons.filled.LocalSee +import androidx.compose.material.icons.filled.LocalShipping +import androidx.compose.material.icons.filled.LocalTaxi +import androidx.compose.material.icons.filled.LocationCity +import androidx.compose.material.icons.filled.LocationDisabled +import androidx.compose.material.icons.filled.LocationOff +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material.icons.filled.LocationSearching +import androidx.compose.material.icons.filled.LunchDining +import androidx.compose.material.icons.filled.Map +import androidx.compose.material.icons.filled.MapsHomeWork +import androidx.compose.material.icons.filled.MapsUgc +import androidx.compose.material.icons.filled.MedicalInformation +import androidx.compose.material.icons.filled.MedicalServices +import androidx.compose.material.icons.filled.Merge +import androidx.compose.material.icons.filled.MinorCrash +import androidx.compose.material.icons.filled.MiscellaneousServices +import androidx.compose.material.icons.filled.ModeOfTravel +import androidx.compose.material.icons.filled.Money +import androidx.compose.material.icons.filled.Mosque +import androidx.compose.material.icons.filled.Moving +import androidx.compose.material.icons.filled.MultipleStop +import androidx.compose.material.icons.filled.Museum +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.Navigation +import androidx.compose.material.icons.filled.NearMe +import androidx.compose.material.icons.filled.NearMeDisabled +import androidx.compose.material.icons.filled.Nightlife +import androidx.compose.material.icons.filled.NoCrash +import androidx.compose.material.icons.filled.NoMeals +import androidx.compose.material.icons.filled.NoTransfer +import androidx.compose.material.icons.filled.NotListedLocation +import androidx.compose.material.icons.filled.Park +import androidx.compose.material.icons.filled.PedalBike +import androidx.compose.material.icons.filled.PersonPin +import androidx.compose.material.icons.filled.PersonPinCircle +import androidx.compose.material.icons.filled.PestControl +import androidx.compose.material.icons.filled.PestControlRodent +import androidx.compose.material.icons.filled.PinDrop +import androidx.compose.material.icons.filled.Place +import androidx.compose.material.icons.filled.Plumbing +import androidx.compose.material.icons.filled.RailwayAlert +import androidx.compose.material.icons.filled.RamenDining +import androidx.compose.material.icons.filled.RampLeft +import androidx.compose.material.icons.filled.RampRight +import androidx.compose.material.icons.filled.RateReview +import androidx.compose.material.icons.filled.RemoveRoad +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.RestaurantMenu +import androidx.compose.material.icons.filled.RoundaboutLeft +import androidx.compose.material.icons.filled.RoundaboutRight +import androidx.compose.material.icons.filled.Route +import androidx.compose.material.icons.filled.RunCircle +import androidx.compose.material.icons.filled.SafetyCheck +import androidx.compose.material.icons.filled.Sailing +import androidx.compose.material.icons.filled.Satellite +import androidx.compose.material.icons.filled.ScreenRotationAlt +import androidx.compose.material.icons.filled.SetMeal +import androidx.compose.material.icons.filled.Signpost +import androidx.compose.material.icons.filled.Snowmobile +import androidx.compose.material.icons.filled.Sos +import androidx.compose.material.icons.filled.SoupKitchen +import androidx.compose.material.icons.filled.Stadium +import androidx.compose.material.icons.filled.StoreMallDirectory +import androidx.compose.material.icons.filled.Straight +import androidx.compose.material.icons.filled.Streetview +import androidx.compose.material.icons.filled.Subway +import androidx.compose.material.icons.filled.Synagogue +import androidx.compose.material.icons.filled.TakeoutDining +import androidx.compose.material.icons.filled.TaxiAlert +import androidx.compose.material.icons.filled.TempleBuddhist +import androidx.compose.material.icons.filled.TempleHindu +import androidx.compose.material.icons.filled.Terrain +import androidx.compose.material.icons.filled.TheaterComedy +import androidx.compose.material.icons.filled.TireRepair +import androidx.compose.material.icons.filled.Traffic +import androidx.compose.material.icons.filled.Train +import androidx.compose.material.icons.filled.Tram +import androidx.compose.material.icons.filled.TransferWithinAStation +import androidx.compose.material.icons.filled.TransitEnterexit +import androidx.compose.material.icons.filled.TripOrigin +import androidx.compose.material.icons.filled.TurnLeft +import androidx.compose.material.icons.filled.TurnRight +import androidx.compose.material.icons.filled.TurnSharpLeft +import androidx.compose.material.icons.filled.TurnSharpRight +import androidx.compose.material.icons.filled.TurnSlightLeft +import androidx.compose.material.icons.filled.TurnSlightRight +import androidx.compose.material.icons.filled.TwoWheeler +import androidx.compose.material.icons.filled.UTurnLeft +import androidx.compose.material.icons.filled.UTurnRight +import androidx.compose.material.icons.filled.VolunteerActivism +import androidx.compose.material.icons.filled.Warehouse +import androidx.compose.material.icons.filled.WineBar +import androidx.compose.material.icons.filled.WrongLocation +import androidx.compose.material.icons.filled.ZoomInMap +import androidx.compose.material.icons.filled.ZoomOutMap +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Maps category icons - Location and navigation + * Based on Google's Material Design Icons taxonomy + */ +object MapsIcons { + val icons = + listOf( + // ProfileIcon("360", Icons.Filled.ThreeSixty, "360"), + ProfileIcon("add_location", Icons.Filled.AddLocation, "Add Location"), + ProfileIcon("add_location_alt", Icons.Filled.AddLocationAlt, "Add Location Alt"), + ProfileIcon("add_road", Icons.Filled.AddRoad, "Add Road"), + ProfileIcon("agriculture", Icons.Filled.Agriculture, "Agriculture"), + ProfileIcon("airline_stops", Icons.Filled.AirlineStops, "Airline Stops"), + ProfileIcon("airlines", Icons.Filled.Airlines, "Airlines"), + ProfileIcon("alt_route", Icons.Filled.AltRoute, "Alt Route"), + ProfileIcon("atm", Icons.Filled.Atm, "ATM"), + ProfileIcon("attractions", Icons.Filled.Attractions, "Attractions"), + ProfileIcon("badge", Icons.Filled.Badge, "Badge"), + ProfileIcon("bakery_dining", Icons.Filled.BakeryDining, "Bakery Dining"), + ProfileIcon("beenhere", Icons.Filled.Beenhere, "Been Here"), + ProfileIcon("bike_scooter", Icons.Filled.BikeScooter, "Bike Scooter"), + ProfileIcon("breakfast_dining", Icons.Filled.BreakfastDining, "Breakfast Dining"), + ProfileIcon("brunch_dining", Icons.Filled.BrunchDining, "Brunch Dining"), + ProfileIcon("bus_alert", Icons.Filled.BusAlert, "Bus Alert"), + ProfileIcon("car_crash", Icons.Filled.CarCrash, "Car Crash"), + ProfileIcon("car_rental", Icons.Filled.CarRental, "Car Rental"), + ProfileIcon("car_repair", Icons.Filled.CarRepair, "Car Repair"), + ProfileIcon("castle", Icons.Filled.Castle, "Castle"), + ProfileIcon("category", Icons.Filled.Category, "Category"), + ProfileIcon("celebration", Icons.Filled.Celebration, "Celebration"), + ProfileIcon("church", Icons.Filled.Church, "Church"), + ProfileIcon("cleaning_services", Icons.Filled.CleaningServices, "Cleaning Services"), + ProfileIcon("compass_calibration", Icons.Filled.CompassCalibration, "Compass Calibration"), + ProfileIcon("connecting_airports", Icons.Filled.ConnectingAirports, "Connecting Airports"), + ProfileIcon("crisis_alert", Icons.Filled.CrisisAlert, "Crisis Alert"), + ProfileIcon("delivery_dining", Icons.Filled.DeliveryDining, "Delivery Dining"), + ProfileIcon("departure_board", Icons.Filled.DepartureBoard, "Departure Board"), + ProfileIcon("design_services", Icons.Filled.DesignServices, "Design Services"), + ProfileIcon("diamond", Icons.Filled.Diamond, "Diamond"), + ProfileIcon("dinner_dining", Icons.Filled.DinnerDining, "Dinner Dining"), + ProfileIcon("directions", Icons.Filled.Directions, "Directions"), + ProfileIcon("directions_bike", Icons.Filled.DirectionsBike, "Directions Bike"), + ProfileIcon("directions_boat", Icons.Filled.DirectionsBoat, "Directions Boat"), + ProfileIcon("directions_boat_filled", Icons.Filled.DirectionsBoatFilled, "Boat Filled"), + ProfileIcon("directions_bus", Icons.Filled.DirectionsBus, "Directions Bus"), + ProfileIcon("directions_bus_filled", Icons.Filled.DirectionsBusFilled, "Bus Filled"), + ProfileIcon("directions_car", Icons.Filled.DirectionsCar, "Directions Car"), + ProfileIcon("directions_car_filled", Icons.Filled.DirectionsCarFilled, "Car Filled"), + ProfileIcon("directions_railway", Icons.Filled.DirectionsRailway, "Railway"), + ProfileIcon( + "directions_railway_filled", + Icons.Filled.DirectionsRailwayFilled, + "Railway Filled", + ), + ProfileIcon("directions_run", Icons.Filled.DirectionsRun, "Directions Run"), + ProfileIcon("directions_subway", Icons.Filled.DirectionsSubway, "Subway"), + ProfileIcon( + "directions_subway_filled", + Icons.Filled.DirectionsSubwayFilled, + "Subway Filled", + ), + ProfileIcon("directions_transit", Icons.Filled.DirectionsTransit, "Transit"), + ProfileIcon( + "directions_transit_filled", + Icons.Filled.DirectionsTransitFilled, + "Transit Filled", + ), + ProfileIcon("directions_walk", Icons.Filled.DirectionsWalk, "Directions Walk"), + ProfileIcon("dry_cleaning", Icons.Filled.DryCleaning, "Dry Cleaning"), + ProfileIcon("edit_attributes", Icons.Filled.EditAttributes, "Edit Attributes"), + ProfileIcon("edit_location", Icons.Filled.EditLocation, "Edit Location"), + ProfileIcon("edit_location_alt", Icons.Filled.EditLocationAlt, "Edit Location Alt"), + ProfileIcon("edit_road", Icons.Filled.EditRoad, "Edit Road"), + ProfileIcon("egg", Icons.Filled.Egg, "Egg"), + ProfileIcon("egg_alt", Icons.Filled.EggAlt, "Egg Alt"), + ProfileIcon("electric_bike", Icons.Filled.ElectricBike, "Electric Bike"), + ProfileIcon("electric_car", Icons.Filled.ElectricCar, "Electric Car"), + ProfileIcon("electric_moped", Icons.Filled.ElectricMoped, "Electric Moped"), + ProfileIcon("electric_rickshaw", Icons.Filled.ElectricRickshaw, "Electric Rickshaw"), + ProfileIcon("electric_scooter", Icons.Filled.ElectricScooter, "Electric Scooter"), + ProfileIcon("electrical_services", Icons.Filled.ElectricalServices, "Electrical Services"), + ProfileIcon("emergency", Icons.Filled.Emergency, "Emergency"), + ProfileIcon("emergency_recording", Icons.Filled.EmergencyRecording, "Emergency Recording"), + ProfileIcon("emergency_share", Icons.Filled.EmergencyShare, "Emergency Share"), + ProfileIcon("ev_station", Icons.Filled.EvStation, "EV Station"), + ProfileIcon("factory", Icons.Filled.Factory, "Factory"), + ProfileIcon("fastfood", Icons.Filled.Fastfood, "Fast Food"), + ProfileIcon("festival", Icons.Filled.Festival, "Festival"), + ProfileIcon("fire_extinguisher", Icons.Filled.FireExtinguisher, "Fire Extinguisher"), + ProfileIcon("fire_hydrant_alt", Icons.Filled.FireHydrantAlt, "Fire Hydrant"), + ProfileIcon("fire_truck", Icons.Filled.FireTruck, "Fire Truck"), + ProfileIcon("flight", Icons.Filled.Flight, "Flight"), + ProfileIcon("flight_class", Icons.Filled.FlightClass, "Flight Class"), + ProfileIcon("flight_land", Icons.Filled.FlightLand, "Flight Land"), + ProfileIcon("flight_takeoff", Icons.Filled.FlightTakeoff, "Flight Takeoff"), + ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"), + ProfileIcon("forest", Icons.Filled.Forest, "Forest"), + ProfileIcon("fork_left", Icons.Filled.ForkLeft, "Fork Left"), + ProfileIcon("fork_right", Icons.Filled.ForkRight, "Fork Right"), + ProfileIcon("fort", Icons.Filled.Fort, "Fort"), + ProfileIcon("hail", Icons.Filled.Hail, "Hail"), + ProfileIcon("handyman", Icons.Filled.Handyman, "Handyman"), + ProfileIcon("hardware", Icons.Filled.Hardware, "Hardware"), + ProfileIcon("home_repair_service", Icons.Filled.HomeRepairService, "Home Repair"), + ProfileIcon("hotel", Icons.Filled.Hotel, "Hotel"), + ProfileIcon("hvac", Icons.Filled.Hvac, "HVAC"), + ProfileIcon("icecream", Icons.Filled.Icecream, "Ice Cream"), + ProfileIcon("kebab_dining", Icons.Filled.KebabDining, "Kebab Dining"), + ProfileIcon("layers", Icons.Filled.Layers, "Layers"), + ProfileIcon("layers_clear", Icons.Filled.LayersClear, "Layers Clear"), + ProfileIcon("liquor", Icons.Filled.Liquor, "Liquor"), + ProfileIcon("local_activity", Icons.Filled.LocalActivity, "Local Activity"), + ProfileIcon("local_airport", Icons.Filled.LocalAirport, "Airport"), + ProfileIcon("local_atm", Icons.Filled.LocalAtm, "ATM"), + ProfileIcon("local_bar", Icons.Filled.LocalBar, "Bar"), + ProfileIcon("local_cafe", Icons.Filled.LocalCafe, "Cafe"), + ProfileIcon("local_car_wash", Icons.Filled.LocalCarWash, "Car Wash"), + ProfileIcon( + "local_convenience_store", + Icons.Filled.LocalConvenienceStore, + "Convenience Store", + ), + ProfileIcon("local_dining", Icons.Filled.LocalDining, "Dining"), + ProfileIcon("local_drink", Icons.Filled.LocalDrink, "Drink"), + ProfileIcon("local_fire_department", Icons.Filled.LocalFireDepartment, "Fire Department"), + ProfileIcon("local_florist", Icons.Filled.LocalFlorist, "Florist"), + ProfileIcon("local_gas_station", Icons.Filled.LocalGasStation, "Gas Station"), + ProfileIcon("local_grocery_store", Icons.Filled.LocalGroceryStore, "Grocery Store"), + ProfileIcon("local_hospital", Icons.Filled.LocalHospital, "Hospital"), + ProfileIcon("local_hotel", Icons.Filled.LocalHotel, "Hotel"), + ProfileIcon("local_laundry_service", Icons.Filled.LocalLaundryService, "Laundry"), + ProfileIcon("local_library", Icons.Filled.LocalLibrary, "Library"), + ProfileIcon("local_mall", Icons.Filled.LocalMall, "Mall"), + ProfileIcon("local_movies", Icons.Filled.LocalMovies, "Movies"), + ProfileIcon("local_offer", Icons.Filled.LocalOffer, "Offer"), + ProfileIcon("local_parking", Icons.Filled.LocalParking, "Parking"), + ProfileIcon("local_pharmacy", Icons.Filled.LocalPharmacy, "Pharmacy"), + ProfileIcon("local_phone", Icons.Filled.LocalPhone, "Phone"), + ProfileIcon("local_pizza", Icons.Filled.LocalPizza, "Pizza"), + ProfileIcon("local_play", Icons.Filled.LocalPlay, "Play"), + ProfileIcon("local_police", Icons.Filled.LocalPolice, "Police"), + ProfileIcon("local_post_office", Icons.Filled.LocalPostOffice, "Post Office"), + ProfileIcon("local_printshop", Icons.Filled.LocalPrintshop, "Print Shop"), + ProfileIcon("local_see", Icons.Filled.LocalSee, "See"), + ProfileIcon("local_shipping", Icons.Filled.LocalShipping, "Shipping"), + ProfileIcon("local_taxi", Icons.Filled.LocalTaxi, "Taxi"), + ProfileIcon("location_city", Icons.Filled.LocationCity, "City"), + ProfileIcon("location_disabled", Icons.Filled.LocationDisabled, "Location Disabled"), + ProfileIcon("location_off", Icons.Filled.LocationOff, "Location Off"), + ProfileIcon("location_on", Icons.Filled.LocationOn, "Location On"), + ProfileIcon("location_searching", Icons.Filled.LocationSearching, "Location Searching"), + ProfileIcon("lunch_dining", Icons.Filled.LunchDining, "Lunch Dining"), + ProfileIcon("map", Icons.Filled.Map, "Map"), + ProfileIcon("maps_home_work", Icons.Filled.MapsHomeWork, "Home Work"), + ProfileIcon("maps_ugc", Icons.Filled.MapsUgc, "Maps UGC"), + ProfileIcon("medical_information", Icons.Filled.MedicalInformation, "Medical Info"), + ProfileIcon("medical_services", Icons.Filled.MedicalServices, "Medical Services"), + ProfileIcon("merge", Icons.Filled.Merge, "Merge"), + ProfileIcon("minor_crash", Icons.Filled.MinorCrash, "Minor Crash"), + ProfileIcon("miscellaneous_services", Icons.Filled.MiscellaneousServices, "Misc Services"), + ProfileIcon("mode_of_travel", Icons.Filled.ModeOfTravel, "Mode of Travel"), + ProfileIcon("money", Icons.Filled.Money, "Money"), + ProfileIcon("mosque", Icons.Filled.Mosque, "Mosque"), + ProfileIcon("moving", Icons.Filled.Moving, "Moving"), + ProfileIcon("multiple_stop", Icons.Filled.MultipleStop, "Multiple Stop"), + ProfileIcon("museum", Icons.Filled.Museum, "Museum"), + ProfileIcon("my_location", Icons.Filled.MyLocation, "My Location"), + ProfileIcon("navigation", Icons.Filled.Navigation, "Navigation"), + ProfileIcon("near_me", Icons.Filled.NearMe, "Near Me"), + ProfileIcon("near_me_disabled", Icons.Filled.NearMeDisabled, "Near Me Disabled"), + ProfileIcon("nightlife", Icons.Filled.Nightlife, "Nightlife"), + ProfileIcon("no_crash", Icons.Filled.NoCrash, "No Crash"), + ProfileIcon("no_meals", Icons.Filled.NoMeals, "No Meals"), + ProfileIcon("no_transfer", Icons.Filled.NoTransfer, "No Transfer"), + ProfileIcon("not_listed_location", Icons.Filled.NotListedLocation, "Not Listed"), + ProfileIcon("park", Icons.Filled.Park, "Park"), + ProfileIcon("pedal_bike", Icons.Filled.PedalBike, "Pedal Bike"), + ProfileIcon("person_pin", Icons.Filled.PersonPin, "Person Pin"), + ProfileIcon("person_pin_circle", Icons.Filled.PersonPinCircle, "Person Pin Circle"), + ProfileIcon("pest_control", Icons.Filled.PestControl, "Pest Control"), + ProfileIcon("pest_control_rodent", Icons.Filled.PestControlRodent, "Pest Rodent"), + ProfileIcon("pin_drop", Icons.Filled.PinDrop, "Pin Drop"), + ProfileIcon("place", Icons.Filled.Place, "Place"), + ProfileIcon("plumbing", Icons.Filled.Plumbing, "Plumbing"), + ProfileIcon("railway_alert", Icons.Filled.RailwayAlert, "Railway Alert"), + ProfileIcon("ramen_dining", Icons.Filled.RamenDining, "Ramen Dining"), + ProfileIcon("ramp_left", Icons.Filled.RampLeft, "Ramp Left"), + ProfileIcon("ramp_right", Icons.Filled.RampRight, "Ramp Right"), + ProfileIcon("rate_review", Icons.Filled.RateReview, "Rate Review"), + ProfileIcon("remove_road", Icons.Filled.RemoveRoad, "Remove Road"), + ProfileIcon("restaurant", Icons.Filled.Restaurant, "Restaurant"), + ProfileIcon("restaurant_menu", Icons.Filled.RestaurantMenu, "Restaurant Menu"), + ProfileIcon("route", Icons.Filled.Route, "Route"), + ProfileIcon("roundabout_left", Icons.Filled.RoundaboutLeft, "Roundabout Left"), + ProfileIcon("roundabout_right", Icons.Filled.RoundaboutRight, "Roundabout Right"), + ProfileIcon("run_circle", Icons.Filled.RunCircle, "Run Circle"), + ProfileIcon("safety_check", Icons.Filled.SafetyCheck, "Safety Check"), + ProfileIcon("sailing", Icons.Filled.Sailing, "Sailing"), + ProfileIcon("satellite", Icons.Filled.Satellite, "Satellite"), + ProfileIcon("screen_rotation_alt", Icons.Filled.ScreenRotationAlt, "Screen Rotation Alt"), + ProfileIcon("set_meal", Icons.Filled.SetMeal, "Set Meal"), + ProfileIcon("signpost", Icons.Filled.Signpost, "Signpost"), + ProfileIcon("snowmobile", Icons.Filled.Snowmobile, "Snowmobile"), + ProfileIcon("sos", Icons.Filled.Sos, "SOS"), + ProfileIcon("soup_kitchen", Icons.Filled.SoupKitchen, "Soup Kitchen"), + ProfileIcon("stadium", Icons.Filled.Stadium, "Stadium"), + ProfileIcon("store_mall_directory", Icons.Filled.StoreMallDirectory, "Mall Directory"), + ProfileIcon("straight", Icons.Filled.Straight, "Straight"), + ProfileIcon("streetview", Icons.Filled.Streetview, "Street View"), + ProfileIcon("subway", Icons.Filled.Subway, "Subway"), + ProfileIcon("synagogue", Icons.Filled.Synagogue, "Synagogue"), + ProfileIcon("takeout_dining", Icons.Filled.TakeoutDining, "Takeout Dining"), + ProfileIcon("taxi_alert", Icons.Filled.TaxiAlert, "Taxi Alert"), + ProfileIcon("temple_buddhist", Icons.Filled.TempleBuddhist, "Buddhist Temple"), + ProfileIcon("temple_hindu", Icons.Filled.TempleHindu, "Hindu Temple"), + ProfileIcon("terrain", Icons.Filled.Terrain, "Terrain"), + ProfileIcon("theater_comedy", Icons.Filled.TheaterComedy, "Theater Comedy"), + ProfileIcon("tire_repair", Icons.Filled.TireRepair, "Tire Repair"), + ProfileIcon("traffic", Icons.Filled.Traffic, "Traffic"), + ProfileIcon("train", Icons.Filled.Train, "Train"), + ProfileIcon("tram", Icons.Filled.Tram, "Tram"), + ProfileIcon( + "transfer_within_a_station", + Icons.Filled.TransferWithinAStation, + "Transfer Station", + ), + ProfileIcon("transit_enterexit", Icons.Filled.TransitEnterexit, "Transit Enter/Exit"), + ProfileIcon("trip_origin", Icons.Filled.TripOrigin, "Trip Origin"), + ProfileIcon("turn_left", Icons.Filled.TurnLeft, "Turn Left"), + ProfileIcon("turn_right", Icons.Filled.TurnRight, "Turn Right"), + ProfileIcon("turn_sharp_left", Icons.Filled.TurnSharpLeft, "Turn Sharp Left"), + ProfileIcon("turn_sharp_right", Icons.Filled.TurnSharpRight, "Turn Sharp Right"), + ProfileIcon("turn_slight_left", Icons.Filled.TurnSlightLeft, "Turn Slight Left"), + ProfileIcon("turn_slight_right", Icons.Filled.TurnSlightRight, "Turn Slight Right"), + ProfileIcon("two_wheeler", Icons.Filled.TwoWheeler, "Two Wheeler"), + ProfileIcon("u_turn_left", Icons.Filled.UTurnLeft, "U-Turn Left"), + ProfileIcon("u_turn_right", Icons.Filled.UTurnRight, "U-Turn Right"), + ProfileIcon("volunteer_activism", Icons.Filled.VolunteerActivism, "Volunteer"), + ProfileIcon("warehouse", Icons.Filled.Warehouse, "Warehouse"), + ProfileIcon("wine_bar", Icons.Filled.WineBar, "Wine Bar"), + ProfileIcon("wrong_location", Icons.Filled.WrongLocation, "Wrong Location"), + ProfileIcon("zoom_in_map", Icons.Filled.ZoomInMap, "Zoom In Map"), + ProfileIcon("zoom_out_map", Icons.Filled.ZoomOutMap, "Zoom Out Map"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt new file mode 100644 index 0000000000..9d2c351680 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/MaterialIconsLibrary.kt @@ -0,0 +1,102 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.ui.graphics.vector.ImageVector +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Complete Material Icons Library following Google's official taxonomy + * Icons are organized into categories as defined by Material Design guidelines + * + * Categories based on https://fonts.google.com/icons taxonomy: + * - Action: User actions and common UI operations + * - Alert: Warnings, errors, and notifications + * - AV (Audio/Video): Media controls and playback + * - Communication: Messaging, calls, emails + * - Content: Content creation and management + * - Device: Device-specific icons and features + * - Editor: Text and content editing + * - File: File types and operations + * - Hardware: Physical hardware and peripherals + * - Image: Image editing and gallery + * - Maps: Location and navigation + * - Navigation: App navigation and menus + * - Notification: Alerts and status updates + * - Places: Locations and venues + * - Social: Social media and sharing + * - Toggle: Switches and toggles + */ +object MaterialIconsLibrary { + /** + * All icon categories following Google's Material Design taxonomy + */ + val categories: List = + listOf( + IconCategory("Action", ActionIcons.icons), + IconCategory("Alert", AlertIcons.icons), + IconCategory("Audio & Video", AVIcons.icons), + IconCategory("Communication", CommunicationIcons.icons), + IconCategory("Content", ContentIcons.icons), + IconCategory("Device", DeviceIcons.icons), + IconCategory("Editor", EditorIcons.icons), + IconCategory("File", FileIcons.icons), + IconCategory("Hardware", HardwareIcons.icons), + IconCategory("Image", ImageIcons.icons), + IconCategory("Maps", MapsIcons.icons), + IconCategory("Navigation", NavigationIcons.icons), + IconCategory("Notification", NotificationIcons.icons), + IconCategory("Places", PlacesIcons.icons), + IconCategory("Social", SocialIcons.icons), + IconCategory("Toggle", ToggleIcons.icons), + ) + + /** + * Get all icons from all categories + */ + fun getAllIcons(): List = categories.flatMap { it.icons } + + /** + * Get an icon by its ID + */ + fun getIconById(id: String): ImageVector? = getAllIcons().find { it.id == id }?.icon + + /** + * Get the category name for a given icon ID + */ + fun getCategoryForIcon(iconId: String): String? { + categories.forEach { category -> + if (category.icons.any { it.id == iconId }) { + return category.name + } + } + return null + } + + /** + * Search icons by query (searches in both ID and label) + */ + fun searchIcons(query: String): List { + if (query.isBlank()) return getAllIcons() + + val lowercaseQuery = query.lowercase() + return getAllIcons().filter { + it.id.contains(lowercaseQuery) || + it.label.lowercase().contains(lowercaseQuery) + } + } + + /** + * Get icons by category name + */ + fun getIconsByCategory(categoryName: String): List = categories.find { it.name.equals(categoryName, ignoreCase = true) }?.icons + ?: emptyList() + + /** + * Get total number of icons in the library + */ + fun getTotalIconCount(): Int = categories.sumOf { it.icons.size } + + /** + * Get category names + */ + fun getCategoryNames(): List = categories.map { it.name } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt new file mode 100644 index 0000000000..8d97d3a487 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NavigationIcons.kt @@ -0,0 +1,137 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBackIos +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.automirrored.filled.ArrowRightAlt +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.automirrored.filled.MenuOpen +import androidx.compose.material.icons.filled.AppSettingsAlt +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.ArrowBackIosNew +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropDownCircle +import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.ArrowLeft +import androidx.compose.material.icons.filled.ArrowRight +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.AssistantDirection +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ChevronLeft +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DoubleArrow +import androidx.compose.material.icons.filled.East +import androidx.compose.material.icons.filled.ExpandCircleDown +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.FirstPage +import androidx.compose.material.icons.filled.Fullscreen +import androidx.compose.material.icons.filled.FullscreenExit +import androidx.compose.material.icons.filled.HomeWork +import androidx.compose.material.icons.filled.LastPage +import androidx.compose.material.icons.filled.LegendToggle +import androidx.compose.material.icons.filled.LiveTv +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MoreHoriz +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.North +import androidx.compose.material.icons.filled.NorthEast +import androidx.compose.material.icons.filled.NorthWest +import androidx.compose.material.icons.filled.OfflineShare +import androidx.compose.material.icons.filled.Payments +import androidx.compose.material.icons.filled.PivotTableChart +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.South +import androidx.compose.material.icons.filled.SouthEast +import androidx.compose.material.icons.filled.SouthWest +import androidx.compose.material.icons.filled.SubdirectoryArrowLeft +import androidx.compose.material.icons.filled.SubdirectoryArrowRight +import androidx.compose.material.icons.filled.SwitchLeft +import androidx.compose.material.icons.filled.SwitchRight +import androidx.compose.material.icons.filled.UnfoldLess +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material.icons.filled.WaterfallChart +import androidx.compose.material.icons.filled.West +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Navigation category icons - App navigation and menus + * Based on Google's Material Design Icons taxonomy + */ +object NavigationIcons { + val icons = + listOf( + ProfileIcon("app_settings_alt", Icons.Filled.AppSettingsAlt, "App Settings"), + ProfileIcon("apps", Icons.Filled.Apps, "Apps"), + ProfileIcon("arrow_back", Icons.AutoMirrored.Filled.ArrowBack, "Arrow Back"), + ProfileIcon("arrow_back_ios", Icons.AutoMirrored.Filled.ArrowBackIos, "Back iOS"), + ProfileIcon("arrow_back_ios_new", Icons.Filled.ArrowBackIosNew, "Back iOS New"), + ProfileIcon("arrow_downward", Icons.Filled.ArrowDownward, "Arrow Down"), + ProfileIcon("arrow_drop_down", Icons.Filled.ArrowDropDown, "Drop Down"), + ProfileIcon("arrow_drop_down_circle", Icons.Filled.ArrowDropDownCircle, "Drop Down Circle"), + ProfileIcon("arrow_drop_up", Icons.Filled.ArrowDropUp, "Drop Up"), + ProfileIcon("arrow_forward", Icons.AutoMirrored.Filled.ArrowForward, "Arrow Forward"), + ProfileIcon("arrow_forward_ios", Icons.AutoMirrored.Filled.ArrowForwardIos, "Forward iOS"), + ProfileIcon("arrow_left", Icons.Filled.ArrowLeft, "Arrow Left"), + ProfileIcon("arrow_right", Icons.Filled.ArrowRight, "Arrow Right"), + ProfileIcon("arrow_right_alt", Icons.AutoMirrored.Filled.ArrowRightAlt, "Arrow Right Alt"), + ProfileIcon("arrow_upward", Icons.Filled.ArrowUpward, "Arrow Up"), + ProfileIcon("assistant_direction", Icons.Filled.AssistantDirection, "Assistant Direction"), + // ProfileIcon("assistant_navigation", Icons.Filled.AssistantNavigation, "Assistant Navigation"), + ProfileIcon("campaign", Icons.Filled.Campaign, "Campaign"), + ProfileIcon("cancel", Icons.Filled.Cancel, "Cancel"), + ProfileIcon("check", Icons.Filled.Check, "Check"), + ProfileIcon("chevron_left", Icons.Filled.ChevronLeft, "Chevron Left"), + ProfileIcon("chevron_right", Icons.Filled.ChevronRight, "Chevron Right"), + ProfileIcon("close", Icons.Filled.Close, "Close"), + ProfileIcon("double_arrow", Icons.Filled.DoubleArrow, "Double Arrow"), + ProfileIcon("east", Icons.Filled.East, "East"), + ProfileIcon("expand_circle_down", Icons.Filled.ExpandCircleDown, "Expand Circle Down"), + ProfileIcon("expand_less", Icons.Filled.ExpandLess, "Expand Less"), + ProfileIcon("expand_more", Icons.Filled.ExpandMore, "Expand More"), + ProfileIcon("first_page", Icons.Filled.FirstPage, "First Page"), + ProfileIcon("fullscreen", Icons.Filled.Fullscreen, "Fullscreen"), + ProfileIcon("fullscreen_exit", Icons.Filled.FullscreenExit, "Fullscreen Exit"), + ProfileIcon("home_work", Icons.Filled.HomeWork, "Home Work"), + ProfileIcon("last_page", Icons.Filled.LastPage, "Last Page"), + ProfileIcon("legend_toggle", Icons.Filled.LegendToggle, "Legend Toggle"), + ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"), + ProfileIcon("menu", Icons.Filled.Menu, "Menu"), + ProfileIcon("menu_book", Icons.AutoMirrored.Filled.MenuBook, "Menu Book"), + ProfileIcon("menu_open", Icons.AutoMirrored.Filled.MenuOpen, "Menu Open"), + ProfileIcon("more_horiz", Icons.Filled.MoreHoriz, "More Horizontal"), + ProfileIcon("more_vert", Icons.Filled.MoreVert, "More Vertical"), + ProfileIcon("north", Icons.Filled.North, "North"), + ProfileIcon("north_east", Icons.Filled.NorthEast, "North East"), + ProfileIcon("north_west", Icons.Filled.NorthWest, "North West"), + ProfileIcon("offline_share", Icons.Filled.OfflineShare, "Offline Share"), + ProfileIcon("payments", Icons.Filled.Payments, "Payments"), + ProfileIcon("pivot_table_chart", Icons.Filled.PivotTableChart, "Pivot Table"), + ProfileIcon("refresh", Icons.Filled.Refresh, "Refresh"), + ProfileIcon("south", Icons.Filled.South, "South"), + ProfileIcon("south_east", Icons.Filled.SouthEast, "South East"), + ProfileIcon("south_west", Icons.Filled.SouthWest, "South West"), + ProfileIcon( + "subdirectory_arrow_left", + Icons.Filled.SubdirectoryArrowLeft, + "Subdirectory Left", + ), + ProfileIcon( + "subdirectory_arrow_right", + Icons.Filled.SubdirectoryArrowRight, + "Subdirectory Right", + ), + ProfileIcon("switch_left", Icons.Filled.SwitchLeft, "Switch Left"), + ProfileIcon("switch_right", Icons.Filled.SwitchRight, "Switch Right"), + ProfileIcon("unfold_less", Icons.Filled.UnfoldLess, "Unfold Less"), + ProfileIcon("unfold_more", Icons.Filled.UnfoldMore, "Unfold More"), + ProfileIcon("waterfall_chart", Icons.Filled.WaterfallChart, "Waterfall Chart"), + ProfileIcon("west", Icons.Filled.West, "West"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt new file mode 100644 index 0000000000..864f6c4c6e --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/NotificationIcons.kt @@ -0,0 +1,186 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.Adb +import androidx.compose.material.icons.filled.AirlineSeatFlat +import androidx.compose.material.icons.filled.AirlineSeatFlatAngled +import androidx.compose.material.icons.filled.AirlineSeatIndividualSuite +import androidx.compose.material.icons.filled.AirlineSeatLegroomExtra +import androidx.compose.material.icons.filled.AirlineSeatLegroomNormal +import androidx.compose.material.icons.filled.AirlineSeatLegroomReduced +import androidx.compose.material.icons.filled.AirlineSeatReclineExtra +import androidx.compose.material.icons.filled.AirlineSeatReclineNormal +import androidx.compose.material.icons.filled.BluetoothAudio +import androidx.compose.material.icons.filled.ConfirmationNumber +import androidx.compose.material.icons.filled.DirectionsOff +import androidx.compose.material.icons.filled.DiscFull +import androidx.compose.material.icons.filled.DoDisturb +import androidx.compose.material.icons.filled.DoDisturbAlt +import androidx.compose.material.icons.filled.DoDisturbOff +import androidx.compose.material.icons.filled.DoDisturbOn +import androidx.compose.material.icons.filled.DoNotDisturb +import androidx.compose.material.icons.filled.DoNotDisturbAlt +import androidx.compose.material.icons.filled.DoNotDisturbOff +import androidx.compose.material.icons.filled.DoNotDisturbOn +import androidx.compose.material.icons.filled.DriveEta +import androidx.compose.material.icons.filled.EnhancedEncryption +import androidx.compose.material.icons.filled.EventAvailable +import androidx.compose.material.icons.filled.EventBusy +import androidx.compose.material.icons.filled.EventNote +import androidx.compose.material.icons.filled.FolderSpecial +import androidx.compose.material.icons.filled.ImagesearchRoller +import androidx.compose.material.icons.filled.LiveTv +import androidx.compose.material.icons.filled.Mms +import androidx.compose.material.icons.filled.More +import androidx.compose.material.icons.filled.NetworkCheck +import androidx.compose.material.icons.filled.NetworkLocked +import androidx.compose.material.icons.filled.NoEncryption +import androidx.compose.material.icons.filled.NoEncryptionGmailerrorred +import androidx.compose.material.icons.filled.OndemandVideo +import androidx.compose.material.icons.filled.PersonalVideo +import androidx.compose.material.icons.filled.PhoneBluetoothSpeaker +import androidx.compose.material.icons.filled.PhoneCallback +import androidx.compose.material.icons.filled.PhoneForwarded +import androidx.compose.material.icons.filled.PhoneInTalk +import androidx.compose.material.icons.filled.PhoneLocked +import androidx.compose.material.icons.filled.PhoneMissed +import androidx.compose.material.icons.filled.PhonePaused +import androidx.compose.material.icons.filled.Power +import androidx.compose.material.icons.filled.PowerOff +import androidx.compose.material.icons.filled.PriorityHigh +import androidx.compose.material.icons.filled.RunningWithErrors +import androidx.compose.material.icons.filled.SdCardAlert +import androidx.compose.material.icons.filled.SimCardAlert +import androidx.compose.material.icons.filled.Sms +import androidx.compose.material.icons.filled.SmsFailed +import androidx.compose.material.icons.filled.SupportAgent +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material.icons.filled.SyncDisabled +import androidx.compose.material.icons.filled.SyncLock +import androidx.compose.material.icons.filled.SyncProblem +import androidx.compose.material.icons.filled.SystemUpdate +import androidx.compose.material.icons.filled.TapAndPlay +import androidx.compose.material.icons.filled.TimeToLeave +import androidx.compose.material.icons.filled.TvOff +import androidx.compose.material.icons.filled.Vibration +import androidx.compose.material.icons.filled.VideoChat +import androidx.compose.material.icons.filled.VoiceChat +import androidx.compose.material.icons.filled.VpnLock +import androidx.compose.material.icons.filled.Wc +import androidx.compose.material.icons.filled.Wifi +import androidx.compose.material.icons.filled.WifiCalling +import androidx.compose.material.icons.filled.WifiOff +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Notification category icons - Alerts and status updates + * Based on Google's Material Design Icons taxonomy + */ +object NotificationIcons { + val icons = + listOf( + ProfileIcon("account_tree", Icons.Filled.AccountTree, "Account Tree"), + ProfileIcon("adb", Icons.Filled.Adb, "ADB"), + ProfileIcon("airline_seat_flat", Icons.Filled.AirlineSeatFlat, "Seat Flat"), + ProfileIcon("airline_seat_flat_angled", Icons.Filled.AirlineSeatFlatAngled, "Seat Angled"), + ProfileIcon( + "airline_seat_individual_suite", + Icons.Filled.AirlineSeatIndividualSuite, + "Seat Suite", + ), + ProfileIcon( + "airline_seat_legroom_extra", + Icons.Filled.AirlineSeatLegroomExtra, + "Legroom Extra", + ), + ProfileIcon( + "airline_seat_legroom_normal", + Icons.Filled.AirlineSeatLegroomNormal, + "Legroom Normal", + ), + ProfileIcon( + "airline_seat_legroom_reduced", + Icons.Filled.AirlineSeatLegroomReduced, + "Legroom Reduced", + ), + ProfileIcon( + "airline_seat_recline_extra", + Icons.Filled.AirlineSeatReclineExtra, + "Recline Extra", + ), + ProfileIcon( + "airline_seat_recline_normal", + Icons.Filled.AirlineSeatReclineNormal, + "Recline Normal", + ), + ProfileIcon("bluetooth_audio", Icons.Filled.BluetoothAudio, "Bluetooth Audio"), + ProfileIcon("confirmation_number", Icons.Filled.ConfirmationNumber, "Confirmation Number"), + ProfileIcon("directions_off", Icons.Filled.DirectionsOff, "Directions Off"), + ProfileIcon("disc_full", Icons.Filled.DiscFull, "Disc Full"), + ProfileIcon("do_disturb", Icons.Filled.DoDisturb, "Do Disturb"), + ProfileIcon("do_disturb_alt", Icons.Filled.DoDisturbAlt, "Do Disturb Alt"), + ProfileIcon("do_disturb_off", Icons.Filled.DoDisturbOff, "Do Disturb Off"), + ProfileIcon("do_disturb_on", Icons.Filled.DoDisturbOn, "Do Disturb On"), + ProfileIcon("do_not_disturb", Icons.Filled.DoNotDisturb, "Do Not Disturb"), + ProfileIcon("do_not_disturb_alt", Icons.Filled.DoNotDisturbAlt, "DND Alt"), + ProfileIcon("do_not_disturb_off", Icons.Filled.DoNotDisturbOff, "DND Off"), + ProfileIcon("do_not_disturb_on", Icons.Filled.DoNotDisturbOn, "DND On"), + ProfileIcon("drive_eta", Icons.Filled.DriveEta, "Drive ETA"), + ProfileIcon("enhanced_encryption", Icons.Filled.EnhancedEncryption, "Enhanced Encryption"), + ProfileIcon("event_available", Icons.Filled.EventAvailable, "Event Available"), + ProfileIcon("event_busy", Icons.Filled.EventBusy, "Event Busy"), + ProfileIcon("event_note", Icons.Filled.EventNote, "Event Note"), + ProfileIcon("folder_special", Icons.Filled.FolderSpecial, "Folder Special"), + ProfileIcon("imagesearch_roller", Icons.Filled.ImagesearchRoller, "Image Search Roller"), + ProfileIcon("live_tv", Icons.Filled.LiveTv, "Live TV"), + ProfileIcon("mms", Icons.Filled.Mms, "MMS"), + ProfileIcon("more", Icons.Filled.More, "More"), + ProfileIcon("network_check", Icons.Filled.NetworkCheck, "Network Check"), + ProfileIcon("network_locked", Icons.Filled.NetworkLocked, "Network Locked"), + ProfileIcon("no_encryption", Icons.Filled.NoEncryption, "No Encryption"), + ProfileIcon( + "no_encryption_gmailerrorred", + Icons.Filled.NoEncryptionGmailerrorred, + "No Encryption Error", + ), + ProfileIcon("ondemand_video", Icons.Filled.OndemandVideo, "On Demand Video"), + ProfileIcon("personal_video", Icons.Filled.PersonalVideo, "Personal Video"), + ProfileIcon( + "phone_bluetooth_speaker", + Icons.Filled.PhoneBluetoothSpeaker, + "Phone Bluetooth", + ), + ProfileIcon("phone_callback", Icons.Filled.PhoneCallback, "Phone Callback"), + ProfileIcon("phone_forwarded", Icons.Filled.PhoneForwarded, "Phone Forwarded"), + ProfileIcon("phone_in_talk", Icons.Filled.PhoneInTalk, "Phone In Talk"), + ProfileIcon("phone_locked", Icons.Filled.PhoneLocked, "Phone Locked"), + ProfileIcon("phone_missed", Icons.Filled.PhoneMissed, "Phone Missed"), + ProfileIcon("phone_paused", Icons.Filled.PhonePaused, "Phone Paused"), + ProfileIcon("power", Icons.Filled.Power, "Power"), + ProfileIcon("power_off", Icons.Filled.PowerOff, "Power Off"), + ProfileIcon("priority_high", Icons.Filled.PriorityHigh, "Priority High"), + ProfileIcon("running_with_errors", Icons.Filled.RunningWithErrors, "Running With Errors"), + ProfileIcon("sd_card_alert", Icons.Filled.SdCardAlert, "SD Card Alert"), + ProfileIcon("sim_card_alert", Icons.Filled.SimCardAlert, "SIM Card Alert"), + ProfileIcon("sms", Icons.Filled.Sms, "SMS"), + ProfileIcon("sms_failed", Icons.Filled.SmsFailed, "SMS Failed"), + ProfileIcon("support_agent", Icons.Filled.SupportAgent, "Support Agent"), + ProfileIcon("sync", Icons.Filled.Sync, "Sync"), + ProfileIcon("sync_disabled", Icons.Filled.SyncDisabled, "Sync Disabled"), + ProfileIcon("sync_lock", Icons.Filled.SyncLock, "Sync Lock"), + ProfileIcon("sync_problem", Icons.Filled.SyncProblem, "Sync Problem"), + ProfileIcon("system_update", Icons.Filled.SystemUpdate, "System Update"), + ProfileIcon("tap_and_play", Icons.Filled.TapAndPlay, "Tap and Play"), + ProfileIcon("time_to_leave", Icons.Filled.TimeToLeave, "Time to Leave"), + ProfileIcon("tv_off", Icons.Filled.TvOff, "TV Off"), + ProfileIcon("vibration", Icons.Filled.Vibration, "Vibration"), + ProfileIcon("video_chat", Icons.Filled.VideoChat, "Video Chat"), + ProfileIcon("voice_chat", Icons.Filled.VoiceChat, "Voice Chat"), + ProfileIcon("vpn_lock", Icons.Filled.VpnLock, "VPN Lock"), + ProfileIcon("wc", Icons.Filled.Wc, "WC"), + ProfileIcon("wifi", Icons.Filled.Wifi, "WiFi"), + ProfileIcon("wifi_calling", Icons.Filled.WifiCalling, "WiFi Calling"), + ProfileIcon("wifi_off", Icons.Filled.WifiOff, "WiFi Off"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt new file mode 100644 index 0000000000..46502bb167 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/PlacesIcons.kt @@ -0,0 +1,179 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AcUnit +import androidx.compose.material.icons.filled.AirportShuttle +import androidx.compose.material.icons.filled.AllInclusive +import androidx.compose.material.icons.filled.Apartment +import androidx.compose.material.icons.filled.BabyChangingStation +import androidx.compose.material.icons.filled.Backpack +import androidx.compose.material.icons.filled.Balcony +import androidx.compose.material.icons.filled.Bathtub +import androidx.compose.material.icons.filled.BeachAccess +import androidx.compose.material.icons.filled.Bento +import androidx.compose.material.icons.filled.Bungalow +import androidx.compose.material.icons.filled.BusinessCenter +import androidx.compose.material.icons.filled.Cabin +import androidx.compose.material.icons.filled.Cake +import androidx.compose.material.icons.filled.Casino +import androidx.compose.material.icons.filled.Chalet +import androidx.compose.material.icons.filled.ChargingStation +import androidx.compose.material.icons.filled.Checkroom +import androidx.compose.material.icons.filled.ChildCare +import androidx.compose.material.icons.filled.ChildFriendly +import androidx.compose.material.icons.filled.CorporateFare +import androidx.compose.material.icons.filled.Cottage +import androidx.compose.material.icons.filled.Countertops +import androidx.compose.material.icons.filled.Crib +import androidx.compose.material.icons.filled.Desk +import androidx.compose.material.icons.filled.DoNotStep +import androidx.compose.material.icons.filled.DoNotTouch +import androidx.compose.material.icons.filled.Dry +import androidx.compose.material.icons.filled.Elevator +import androidx.compose.material.icons.filled.Escalator +import androidx.compose.material.icons.filled.EscalatorWarning +import androidx.compose.material.icons.filled.FamilyRestroom +import androidx.compose.material.icons.filled.Fence +import androidx.compose.material.icons.filled.FitnessCenter +import androidx.compose.material.icons.filled.FoodBank +import androidx.compose.material.icons.filled.Foundation +import androidx.compose.material.icons.filled.FreeBreakfast +import androidx.compose.material.icons.filled.Gite +import androidx.compose.material.icons.filled.GolfCourse +import androidx.compose.material.icons.filled.Grass +import androidx.compose.material.icons.filled.HolidayVillage +import androidx.compose.material.icons.filled.HotTub +import androidx.compose.material.icons.filled.House +import androidx.compose.material.icons.filled.HouseSiding +import androidx.compose.material.icons.filled.Houseboat +import androidx.compose.material.icons.filled.Iron +import androidx.compose.material.icons.filled.Kitchen +import androidx.compose.material.icons.filled.MeetingRoom +import androidx.compose.material.icons.filled.Microwave +import androidx.compose.material.icons.filled.NightShelter +import androidx.compose.material.icons.filled.NoBackpack +import androidx.compose.material.icons.filled.NoCell +import androidx.compose.material.icons.filled.NoDrinks +import androidx.compose.material.icons.filled.NoFlash +import androidx.compose.material.icons.filled.NoFood +import androidx.compose.material.icons.filled.NoMeetingRoom +import androidx.compose.material.icons.filled.NoPhotography +import androidx.compose.material.icons.filled.NoStroller +import androidx.compose.material.icons.filled.OtherHouses +import androidx.compose.material.icons.filled.Pool +import androidx.compose.material.icons.filled.RiceBowl +import androidx.compose.material.icons.filled.Roofing +import androidx.compose.material.icons.filled.RoomPreferences +import androidx.compose.material.icons.filled.RoomService +import androidx.compose.material.icons.filled.RvHookup +import androidx.compose.material.icons.filled.Shower +import androidx.compose.material.icons.filled.SmokeFree +import androidx.compose.material.icons.filled.SmokingRooms +import androidx.compose.material.icons.filled.Soap +import androidx.compose.material.icons.filled.Spa +import androidx.compose.material.icons.filled.SportsBar +import androidx.compose.material.icons.filled.Stairs +import androidx.compose.material.icons.filled.Storefront +import androidx.compose.material.icons.filled.Stroller +import androidx.compose.material.icons.filled.Tapas +import androidx.compose.material.icons.filled.Tty +import androidx.compose.material.icons.filled.Umbrella +import androidx.compose.material.icons.filled.VapingRooms +import androidx.compose.material.icons.filled.Villa +import androidx.compose.material.icons.filled.Wash +import androidx.compose.material.icons.filled.WaterDamage +import androidx.compose.material.icons.filled.WheelchairPickup +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Places category icons - Locations and venues + * Based on Google's Material Design Icons taxonomy + */ +object PlacesIcons { + val icons = + listOf( + ProfileIcon("ac_unit", Icons.Filled.AcUnit, "AC Unit"), + ProfileIcon("airport_shuttle", Icons.Filled.AirportShuttle, "Airport Shuttle"), + ProfileIcon("all_inclusive", Icons.Filled.AllInclusive, "All Inclusive"), + ProfileIcon("apartment", Icons.Filled.Apartment, "Apartment"), + ProfileIcon("baby_changing_station", Icons.Filled.BabyChangingStation, "Baby Station"), + ProfileIcon("backpack", Icons.Filled.Backpack, "Backpack"), + ProfileIcon("balcony", Icons.Filled.Balcony, "Balcony"), + ProfileIcon("bathtub", Icons.Filled.Bathtub, "Bathtub"), + ProfileIcon("beach_access", Icons.Filled.BeachAccess, "Beach Access"), + ProfileIcon("bento", Icons.Filled.Bento, "Bento"), + ProfileIcon("bungalow", Icons.Filled.Bungalow, "Bungalow"), + ProfileIcon("business_center", Icons.Filled.BusinessCenter, "Business Center"), + ProfileIcon("cabin", Icons.Filled.Cabin, "Cabin"), + ProfileIcon("cake", Icons.Filled.Cake, "Cake"), + ProfileIcon("casino", Icons.Filled.Casino, "Casino"), + ProfileIcon("chalet", Icons.Filled.Chalet, "Chalet"), + ProfileIcon("charging_station", Icons.Filled.ChargingStation, "Charging Station"), + ProfileIcon("checkroom", Icons.Filled.Checkroom, "Checkroom"), + ProfileIcon("child_care", Icons.Filled.ChildCare, "Child Care"), + ProfileIcon("child_friendly", Icons.Filled.ChildFriendly, "Child Friendly"), + ProfileIcon("corporate_fare", Icons.Filled.CorporateFare, "Corporate Fare"), + ProfileIcon("cottage", Icons.Filled.Cottage, "Cottage"), + ProfileIcon("countertops", Icons.Filled.Countertops, "Countertops"), + ProfileIcon("crib", Icons.Filled.Crib, "Crib"), + ProfileIcon("desk", Icons.Filled.Desk, "Desk"), + ProfileIcon("do_not_step", Icons.Filled.DoNotStep, "Do Not Step"), + ProfileIcon("do_not_touch", Icons.Filled.DoNotTouch, "Do Not Touch"), + ProfileIcon("dry", Icons.Filled.Dry, "Dry"), + ProfileIcon("elevator", Icons.Filled.Elevator, "Elevator"), + ProfileIcon("escalator", Icons.Filled.Escalator, "Escalator"), + ProfileIcon("escalator_warning", Icons.Filled.EscalatorWarning, "Escalator Warning"), + ProfileIcon("family_restroom", Icons.Filled.FamilyRestroom, "Family Restroom"), + ProfileIcon("fence", Icons.Filled.Fence, "Fence"), + // ProfileIcon("fire_hydrant", Icons.Filled.FireHydrant, "Fire Hydrant"), + ProfileIcon("fitness_center", Icons.Filled.FitnessCenter, "Fitness Center"), + ProfileIcon("food_bank", Icons.Filled.FoodBank, "Food Bank"), + ProfileIcon("foundation", Icons.Filled.Foundation, "Foundation"), + ProfileIcon("free_breakfast", Icons.Filled.FreeBreakfast, "Free Breakfast"), + ProfileIcon("gite", Icons.Filled.Gite, "Gite"), + ProfileIcon("golf_course", Icons.Filled.GolfCourse, "Golf Course"), + ProfileIcon("grass", Icons.Filled.Grass, "Grass"), + ProfileIcon("holiday_village", Icons.Filled.HolidayVillage, "Holiday Village"), + ProfileIcon("hot_tub", Icons.Filled.HotTub, "Hot Tub"), + ProfileIcon("house", Icons.Filled.House, "House"), + ProfileIcon("house_siding", Icons.Filled.HouseSiding, "House Siding"), + ProfileIcon("houseboat", Icons.Filled.Houseboat, "Houseboat"), + ProfileIcon("iron", Icons.Filled.Iron, "Iron"), + ProfileIcon("kitchen", Icons.Filled.Kitchen, "Kitchen"), + ProfileIcon("meeting_room", Icons.Filled.MeetingRoom, "Meeting Room"), + ProfileIcon("microwave", Icons.Filled.Microwave, "Microwave"), + ProfileIcon("night_shelter", Icons.Filled.NightShelter, "Night Shelter"), + ProfileIcon("no_backpack", Icons.Filled.NoBackpack, "No Backpack"), + ProfileIcon("no_cell", Icons.Filled.NoCell, "No Cell"), + ProfileIcon("no_drinks", Icons.Filled.NoDrinks, "No Drinks"), + ProfileIcon("no_flash", Icons.Filled.NoFlash, "No Flash"), + ProfileIcon("no_food", Icons.Filled.NoFood, "No Food"), + ProfileIcon("no_meeting_room", Icons.Filled.NoMeetingRoom, "No Meeting Room"), + ProfileIcon("no_photography", Icons.Filled.NoPhotography, "No Photography"), + ProfileIcon("no_stroller", Icons.Filled.NoStroller, "No Stroller"), + ProfileIcon("other_houses", Icons.Filled.OtherHouses, "Other Houses"), + ProfileIcon("pool", Icons.Filled.Pool, "Pool"), + ProfileIcon("rice_bowl", Icons.Filled.RiceBowl, "Rice Bowl"), + ProfileIcon("roofing", Icons.Filled.Roofing, "Roofing"), + ProfileIcon("room_preferences", Icons.Filled.RoomPreferences, "Room Preferences"), + ProfileIcon("room_service", Icons.Filled.RoomService, "Room Service"), + ProfileIcon("rv_hookup", Icons.Filled.RvHookup, "RV Hookup"), + ProfileIcon("shower", Icons.Filled.Shower, "Shower"), + ProfileIcon("smoke_free", Icons.Filled.SmokeFree, "Smoke Free"), + ProfileIcon("smoking_rooms", Icons.Filled.SmokingRooms, "Smoking Rooms"), + ProfileIcon("soap", Icons.Filled.Soap, "Soap"), + ProfileIcon("spa", Icons.Filled.Spa, "Spa"), + ProfileIcon("sports_bar", Icons.Filled.SportsBar, "Sports Bar"), + ProfileIcon("stairs", Icons.Filled.Stairs, "Stairs"), + ProfileIcon("storefront", Icons.Filled.Storefront, "Storefront"), + ProfileIcon("stroller", Icons.Filled.Stroller, "Stroller"), + ProfileIcon("tapas", Icons.Filled.Tapas, "Tapas"), + ProfileIcon("tty", Icons.Filled.Tty, "TTY"), + ProfileIcon("umbrella", Icons.Filled.Umbrella, "Umbrella"), + ProfileIcon("vaping_rooms", Icons.Filled.VapingRooms, "Vaping Rooms"), + ProfileIcon("villa", Icons.Filled.Villa, "Villa"), + ProfileIcon("wash", Icons.Filled.Wash, "Wash"), + ProfileIcon("water_damage", Icons.Filled.WaterDamage, "Water Damage"), + ProfileIcon("wheelchair_pickup", Icons.Filled.WheelchairPickup, "Wheelchair Pickup"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt new file mode 100644 index 0000000000..160dca9d2a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/SocialIcons.kt @@ -0,0 +1,422 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddModerator +import androidx.compose.material.icons.filled.AddReaction +import androidx.compose.material.icons.filled.Architecture +import androidx.compose.material.icons.filled.AssistWalker +import androidx.compose.material.icons.filled.BackHand +import androidx.compose.material.icons.filled.Blind +import androidx.compose.material.icons.filled.Boy +import androidx.compose.material.icons.filled.Cake +import androidx.compose.material.icons.filled.CatchingPokemon +import androidx.compose.material.icons.filled.CleanHands +import androidx.compose.material.icons.filled.Co2 +import androidx.compose.material.icons.filled.Compost +import androidx.compose.material.icons.filled.ConnectWithoutContact +import androidx.compose.material.icons.filled.Construction +import androidx.compose.material.icons.filled.Cookie +import androidx.compose.material.icons.filled.Coronavirus +import androidx.compose.material.icons.filled.CrueltyFree +import androidx.compose.material.icons.filled.Cyclone +import androidx.compose.material.icons.filled.Deck +import androidx.compose.material.icons.filled.Diversity1 +import androidx.compose.material.icons.filled.Diversity2 +import androidx.compose.material.icons.filled.Diversity3 +import androidx.compose.material.icons.filled.Domain +import androidx.compose.material.icons.filled.DomainAdd +import androidx.compose.material.icons.filled.DownhillSkiing +import androidx.compose.material.icons.filled.EditNotifications +import androidx.compose.material.icons.filled.Elderly +import androidx.compose.material.icons.filled.ElderlyWoman +import androidx.compose.material.icons.filled.EmojiEmotions +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.EmojiFlags +import androidx.compose.material.icons.filled.EmojiFoodBeverage +import androidx.compose.material.icons.filled.EmojiNature +import androidx.compose.material.icons.filled.EmojiObjects +import androidx.compose.material.icons.filled.EmojiPeople +import androidx.compose.material.icons.filled.EmojiSymbols +import androidx.compose.material.icons.filled.EmojiTransportation +import androidx.compose.material.icons.filled.Engineering +import androidx.compose.material.icons.filled.Face +import androidx.compose.material.icons.filled.Face2 +import androidx.compose.material.icons.filled.Face3 +import androidx.compose.material.icons.filled.Face4 +import androidx.compose.material.icons.filled.Face5 +import androidx.compose.material.icons.filled.Face6 +import androidx.compose.material.icons.filled.Facebook +import androidx.compose.material.icons.filled.Female +import androidx.compose.material.icons.filled.Fireplace +import androidx.compose.material.icons.filled.Fitbit +import androidx.compose.material.icons.filled.Flood +import androidx.compose.material.icons.filled.FollowTheSigns +import androidx.compose.material.icons.filled.FrontHand +import androidx.compose.material.icons.filled.Girl +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.GroupAdd +import androidx.compose.material.icons.filled.GroupOff +import androidx.compose.material.icons.filled.GroupRemove +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Groups2 +import androidx.compose.material.icons.filled.Groups3 +import androidx.compose.material.icons.filled.Handshake +import androidx.compose.material.icons.filled.HealthAndSafety +import androidx.compose.material.icons.filled.HeartBroken +import androidx.compose.material.icons.filled.Hiking +import androidx.compose.material.icons.filled.HistoryEdu +import androidx.compose.material.icons.filled.Hive +import androidx.compose.material.icons.filled.IceSkating +import androidx.compose.material.icons.filled.Interests +import androidx.compose.material.icons.filled.IosShare +import androidx.compose.material.icons.filled.Kayaking +import androidx.compose.material.icons.filled.KingBed +import androidx.compose.material.icons.filled.Kitesurfing +import androidx.compose.material.icons.filled.Landslide +import androidx.compose.material.icons.filled.LocationCity +import androidx.compose.material.icons.filled.Luggage +import androidx.compose.material.icons.filled.Male +import androidx.compose.material.icons.filled.Man +import androidx.compose.material.icons.filled.Man2 +import androidx.compose.material.icons.filled.Man3 +import androidx.compose.material.icons.filled.Man4 +import androidx.compose.material.icons.filled.Masks +import androidx.compose.material.icons.filled.MilitaryTech +import androidx.compose.material.icons.filled.Mood +import androidx.compose.material.icons.filled.MoodBad +import androidx.compose.material.icons.filled.NightsStay +import androidx.compose.material.icons.filled.NoAdultContent +import androidx.compose.material.icons.filled.NoLuggage +import androidx.compose.material.icons.filled.NordicWalking +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.NotificationsNone +import androidx.compose.material.icons.filled.NotificationsOff +import androidx.compose.material.icons.filled.NotificationsPaused +import androidx.compose.material.icons.filled.OutdoorGrill +import androidx.compose.material.icons.filled.Pages +import androidx.compose.material.icons.filled.Paragliding +import androidx.compose.material.icons.filled.PartyMode +import androidx.compose.material.icons.filled.People +import androidx.compose.material.icons.filled.PeopleAlt +import androidx.compose.material.icons.filled.PeopleOutline +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Person2 +import androidx.compose.material.icons.filled.Person3 +import androidx.compose.material.icons.filled.Person4 +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.PersonAddAlt +import androidx.compose.material.icons.filled.PersonAddAlt1 +import androidx.compose.material.icons.filled.PersonOff +import androidx.compose.material.icons.filled.PersonOutline +import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material.icons.filled.PersonRemoveAlt1 +import androidx.compose.material.icons.filled.PersonalInjury +import androidx.compose.material.icons.filled.Piano +import androidx.compose.material.icons.filled.PianoOff +import androidx.compose.material.icons.filled.Pix +import androidx.compose.material.icons.filled.PlusOne +import androidx.compose.material.icons.filled.Poll +import androidx.compose.material.icons.filled.PrecisionManufacturing +import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material.icons.filled.PsychologyAlt +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.PublicOff +import androidx.compose.material.icons.filled.RealEstateAgent +import androidx.compose.material.icons.filled.Recommend +import androidx.compose.material.icons.filled.Recycling +import androidx.compose.material.icons.filled.ReduceCapacity +import androidx.compose.material.icons.filled.RemoveModerator +import androidx.compose.material.icons.filled.RollerSkating +import androidx.compose.material.icons.filled.SafetyDivider +import androidx.compose.material.icons.filled.Sanitizer +import androidx.compose.material.icons.filled.Scale +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.Science +import androidx.compose.material.icons.filled.Scoreboard +import androidx.compose.material.icons.filled.ScubaDiving +import androidx.compose.material.icons.filled.SelfImprovement +import androidx.compose.material.icons.filled.SentimentDissatisfied +import androidx.compose.material.icons.filled.SentimentNeutral +import androidx.compose.material.icons.filled.SentimentSatisfied +import androidx.compose.material.icons.filled.SentimentSatisfiedAlt +import androidx.compose.material.icons.filled.SentimentVeryDissatisfied +import androidx.compose.material.icons.filled.SentimentVerySatisfied +import androidx.compose.material.icons.filled.SevereCold +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.filled.Sick +import androidx.compose.material.icons.filled.SignLanguage +import androidx.compose.material.icons.filled.SingleBed +import androidx.compose.material.icons.filled.Skateboarding +import androidx.compose.material.icons.filled.Sledding +import androidx.compose.material.icons.filled.Snowboarding +import androidx.compose.material.icons.filled.Snowshoeing +import androidx.compose.material.icons.filled.SocialDistance +import androidx.compose.material.icons.filled.SouthAmerica +import androidx.compose.material.icons.filled.Sports +import androidx.compose.material.icons.filled.SportsBaseball +import androidx.compose.material.icons.filled.SportsBasketball +import androidx.compose.material.icons.filled.SportsCricket +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.SportsFootball +import androidx.compose.material.icons.filled.SportsGolf +import androidx.compose.material.icons.filled.SportsGymnastics +import androidx.compose.material.icons.filled.SportsHandball +import androidx.compose.material.icons.filled.SportsHockey +import androidx.compose.material.icons.filled.SportsKabaddi +import androidx.compose.material.icons.filled.SportsMartialArts +import androidx.compose.material.icons.filled.SportsMma +import androidx.compose.material.icons.filled.SportsMotorsports +import androidx.compose.material.icons.filled.SportsRugby +import androidx.compose.material.icons.filled.SportsSoccer +import androidx.compose.material.icons.filled.SportsTennis +import androidx.compose.material.icons.filled.SportsVolleyball +import androidx.compose.material.icons.filled.Surfing +import androidx.compose.material.icons.filled.SwitchAccount +import androidx.compose.material.icons.filled.ThumbDownAlt +import androidx.compose.material.icons.filled.ThumbUpAlt +import androidx.compose.material.icons.filled.Thunderstorm +import androidx.compose.material.icons.filled.Tornado +import androidx.compose.material.icons.filled.Transgender +import androidx.compose.material.icons.filled.TravelExplore +import androidx.compose.material.icons.filled.Tsunami +import androidx.compose.material.icons.filled.Vaccines +import androidx.compose.material.icons.filled.Volcano +import androidx.compose.material.icons.filled.Wallet +import androidx.compose.material.icons.filled.WaterDrop +import androidx.compose.material.icons.filled.WavingHand +import androidx.compose.material.icons.filled.Whatshot +import androidx.compose.material.icons.filled.Woman +import androidx.compose.material.icons.filled.Woman2 +import androidx.compose.material.icons.filled.WorkspacePremium +import androidx.compose.material.icons.filled.Workspaces +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Social category icons - Social media and sharing + * Based on Google's Material Design Icons taxonomy + */ +object SocialIcons { + val icons = + listOf( + // ProfileIcon("6_ft_apart", Icons.Filled.SixFtApart, "6 Ft Apart"), + ProfileIcon("add_moderator", Icons.Filled.AddModerator, "Add Moderator"), + ProfileIcon("add_reaction", Icons.Filled.AddReaction, "Add Reaction"), + ProfileIcon("architecture", Icons.Filled.Architecture, "Architecture"), + ProfileIcon("assist_walker", Icons.Filled.AssistWalker, "Assist Walker"), + ProfileIcon("back_hand", Icons.Filled.BackHand, "Back Hand"), + ProfileIcon("blind", Icons.Filled.Blind, "Blind"), + ProfileIcon("boy", Icons.Filled.Boy, "Boy"), + ProfileIcon("cake", Icons.Filled.Cake, "Cake"), + ProfileIcon("catching_pokemon", Icons.Filled.CatchingPokemon, "Catching Pokemon"), + ProfileIcon("clean_hands", Icons.Filled.CleanHands, "Clean Hands"), + ProfileIcon("co2", Icons.Filled.Co2, "CO2"), + ProfileIcon("compost", Icons.Filled.Compost, "Compost"), + ProfileIcon( + "connect_without_contact", + Icons.Filled.ConnectWithoutContact, + "Connect Without Contact", + ), + ProfileIcon("construction", Icons.Filled.Construction, "Construction"), + ProfileIcon("cookie", Icons.Filled.Cookie, "Cookie"), + ProfileIcon("coronavirus", Icons.Filled.Coronavirus, "Coronavirus"), + ProfileIcon("cruelty_free", Icons.Filled.CrueltyFree, "Cruelty Free"), + ProfileIcon("cyclone", Icons.Filled.Cyclone, "Cyclone"), + ProfileIcon("deck", Icons.Filled.Deck, "Deck"), + ProfileIcon("diversity_1", Icons.Filled.Diversity1, "Diversity 1"), + ProfileIcon("diversity_2", Icons.Filled.Diversity2, "Diversity 2"), + ProfileIcon("diversity_3", Icons.Filled.Diversity3, "Diversity 3"), + ProfileIcon("domain", Icons.Filled.Domain, "Domain"), + ProfileIcon("domain_add", Icons.Filled.DomainAdd, "Domain Add"), + ProfileIcon("downhill_skiing", Icons.Filled.DownhillSkiing, "Downhill Skiing"), + ProfileIcon("edit_notifications", Icons.Filled.EditNotifications, "Edit Notifications"), + ProfileIcon("elderly", Icons.Filled.Elderly, "Elderly"), + ProfileIcon("elderly_woman", Icons.Filled.ElderlyWoman, "Elderly Woman"), + ProfileIcon("emoji_emotions", Icons.Filled.EmojiEmotions, "Emoji Emotions"), + ProfileIcon("emoji_events", Icons.Filled.EmojiEvents, "Emoji Events"), + ProfileIcon("emoji_flags", Icons.Filled.EmojiFlags, "Emoji Flags"), + ProfileIcon("emoji_food_beverage", Icons.Filled.EmojiFoodBeverage, "Food Beverage"), + ProfileIcon("emoji_nature", Icons.Filled.EmojiNature, "Emoji Nature"), + ProfileIcon("emoji_objects", Icons.Filled.EmojiObjects, "Emoji Objects"), + ProfileIcon("emoji_people", Icons.Filled.EmojiPeople, "Emoji People"), + ProfileIcon("emoji_symbols", Icons.Filled.EmojiSymbols, "Emoji Symbols"), + ProfileIcon( + "emoji_transportation", + Icons.Filled.EmojiTransportation, + "Emoji Transportation", + ), + ProfileIcon("engineering", Icons.Filled.Engineering, "Engineering"), + ProfileIcon("face", Icons.Filled.Face, "Face"), + ProfileIcon("face_2", Icons.Filled.Face2, "Face 2"), + ProfileIcon("face_3", Icons.Filled.Face3, "Face 3"), + ProfileIcon("face_4", Icons.Filled.Face4, "Face 4"), + ProfileIcon("face_5", Icons.Filled.Face5, "Face 5"), + ProfileIcon("face_6", Icons.Filled.Face6, "Face 6"), + ProfileIcon("facebook", Icons.Filled.Facebook, "Facebook"), + ProfileIcon("female", Icons.Filled.Female, "Female"), + ProfileIcon("fireplace", Icons.Filled.Fireplace, "Fireplace"), + ProfileIcon("fitbit", Icons.Filled.Fitbit, "Fitbit"), + ProfileIcon("flood", Icons.Filled.Flood, "Flood"), + ProfileIcon("follow_the_signs", Icons.Filled.FollowTheSigns, "Follow Signs"), + ProfileIcon("front_hand", Icons.Filled.FrontHand, "Front Hand"), + ProfileIcon("girl", Icons.Filled.Girl, "Girl"), + ProfileIcon("group", Icons.Filled.Group, "Group"), + ProfileIcon("group_add", Icons.Filled.GroupAdd, "Group Add"), + ProfileIcon("group_off", Icons.Filled.GroupOff, "Group Off"), + ProfileIcon("group_remove", Icons.Filled.GroupRemove, "Group Remove"), + ProfileIcon("groups", Icons.Filled.Groups, "Groups"), + ProfileIcon("groups_2", Icons.Filled.Groups2, "Groups 2"), + ProfileIcon("groups_3", Icons.Filled.Groups3, "Groups 3"), + ProfileIcon("handshake", Icons.Filled.Handshake, "Handshake"), + ProfileIcon("health_and_safety", Icons.Filled.HealthAndSafety, "Health Safety"), + ProfileIcon("heart_broken", Icons.Filled.HeartBroken, "Heart Broken"), + ProfileIcon("hiking", Icons.Filled.Hiking, "Hiking"), + ProfileIcon("history_edu", Icons.Filled.HistoryEdu, "History Edu"), + ProfileIcon("hive", Icons.Filled.Hive, "Hive"), + ProfileIcon("ice_skating", Icons.Filled.IceSkating, "Ice Skating"), + ProfileIcon("interests", Icons.Filled.Interests, "Interests"), + ProfileIcon("ios_share", Icons.Filled.IosShare, "iOS Share"), + ProfileIcon("kayaking", Icons.Filled.Kayaking, "Kayaking"), + ProfileIcon("king_bed", Icons.Filled.KingBed, "King Bed"), + ProfileIcon("kitesurfing", Icons.Filled.Kitesurfing, "Kitesurfing"), + ProfileIcon("landslide", Icons.Filled.Landslide, "Landslide"), + ProfileIcon("location_city", Icons.Filled.LocationCity, "Location City"), + ProfileIcon("luggage", Icons.Filled.Luggage, "Luggage"), + ProfileIcon("male", Icons.Filled.Male, "Male"), + ProfileIcon("man", Icons.Filled.Man, "Man"), + ProfileIcon("man_2", Icons.Filled.Man2, "Man 2"), + ProfileIcon("man_3", Icons.Filled.Man3, "Man 3"), + ProfileIcon("man_4", Icons.Filled.Man4, "Man 4"), + ProfileIcon("masks", Icons.Filled.Masks, "Masks"), + ProfileIcon("military_tech", Icons.Filled.MilitaryTech, "Military Tech"), + ProfileIcon("mood", Icons.Filled.Mood, "Mood"), + ProfileIcon("mood_bad", Icons.Filled.MoodBad, "Mood Bad"), + ProfileIcon("nights_stay", Icons.Filled.NightsStay, "Nights Stay"), + ProfileIcon("no_adult_content", Icons.Filled.NoAdultContent, "No Adult Content"), + ProfileIcon("no_luggage", Icons.Filled.NoLuggage, "No Luggage"), + ProfileIcon("nordic_walking", Icons.Filled.NordicWalking, "Nordic Walking"), + ProfileIcon("notifications", Icons.Filled.Notifications, "Notifications"), + ProfileIcon( + "notifications_active", + Icons.Filled.NotificationsActive, + "Notifications Active", + ), + ProfileIcon("notifications_none", Icons.Filled.NotificationsNone, "Notifications None"), + ProfileIcon("notifications_off", Icons.Filled.NotificationsOff, "Notifications Off"), + ProfileIcon( + "notifications_paused", + Icons.Filled.NotificationsPaused, + "Notifications Paused", + ), + ProfileIcon("outdoor_grill", Icons.Filled.OutdoorGrill, "Outdoor Grill"), + ProfileIcon("pages", Icons.Filled.Pages, "Pages"), + ProfileIcon("paragliding", Icons.Filled.Paragliding, "Paragliding"), + ProfileIcon("party_mode", Icons.Filled.PartyMode, "Party Mode"), + ProfileIcon("people", Icons.Filled.People, "People"), + ProfileIcon("people_alt", Icons.Filled.PeopleAlt, "People Alt"), + ProfileIcon("people_outline", Icons.Filled.PeopleOutline, "People Outline"), + ProfileIcon("person", Icons.Filled.Person, "Person"), + ProfileIcon("person_2", Icons.Filled.Person2, "Person 2"), + ProfileIcon("person_3", Icons.Filled.Person3, "Person 3"), + ProfileIcon("person_4", Icons.Filled.Person4, "Person 4"), + ProfileIcon("person_add", Icons.Filled.PersonAdd, "Person Add"), + ProfileIcon("person_add_alt", Icons.Filled.PersonAddAlt, "Person Add Alt"), + ProfileIcon("person_add_alt_1", Icons.Filled.PersonAddAlt1, "Person Add Alt 1"), + ProfileIcon("person_off", Icons.Filled.PersonOff, "Person Off"), + ProfileIcon("person_outline", Icons.Filled.PersonOutline, "Person Outline"), + ProfileIcon("person_remove", Icons.Filled.PersonRemove, "Person Remove"), + ProfileIcon("person_remove_alt_1", Icons.Filled.PersonRemoveAlt1, "Person Remove Alt"), + ProfileIcon("personal_injury", Icons.Filled.PersonalInjury, "Personal Injury"), + ProfileIcon("piano", Icons.Filled.Piano, "Piano"), + ProfileIcon("piano_off", Icons.Filled.PianoOff, "Piano Off"), + ProfileIcon("pix", Icons.Filled.Pix, "Pix"), + ProfileIcon("plus_one", Icons.Filled.PlusOne, "Plus One"), + ProfileIcon("poll", Icons.Filled.Poll, "Poll"), + ProfileIcon( + "precision_manufacturing", + Icons.Filled.PrecisionManufacturing, + "Precision Manufacturing", + ), + ProfileIcon("psychology", Icons.Filled.Psychology, "Psychology"), + ProfileIcon("psychology_alt", Icons.Filled.PsychologyAlt, "Psychology Alt"), + ProfileIcon("public", Icons.Filled.Public, "Public"), + ProfileIcon("public_off", Icons.Filled.PublicOff, "Public Off"), + ProfileIcon("real_estate_agent", Icons.Filled.RealEstateAgent, "Real Estate Agent"), + ProfileIcon("recommend", Icons.Filled.Recommend, "Recommend"), + ProfileIcon("recycling", Icons.Filled.Recycling, "Recycling"), + ProfileIcon("reduce_capacity", Icons.Filled.ReduceCapacity, "Reduce Capacity"), + ProfileIcon("remove_moderator", Icons.Filled.RemoveModerator, "Remove Moderator"), + ProfileIcon("roller_skating", Icons.Filled.RollerSkating, "Roller Skating"), + ProfileIcon("safety_divider", Icons.Filled.SafetyDivider, "Safety Divider"), + ProfileIcon("sanitizer", Icons.Filled.Sanitizer, "Sanitizer"), + ProfileIcon("scale", Icons.Filled.Scale, "Scale"), + ProfileIcon("school", Icons.Filled.School, "School"), + ProfileIcon("science", Icons.Filled.Science, "Science"), + ProfileIcon("scoreboard", Icons.Filled.Scoreboard, "Scoreboard"), + ProfileIcon("scuba_diving", Icons.Filled.ScubaDiving, "Scuba Diving"), + ProfileIcon("self_improvement", Icons.Filled.SelfImprovement, "Self Improvement"), + ProfileIcon("sentiment_dissatisfied", Icons.Filled.SentimentDissatisfied, "Dissatisfied"), + ProfileIcon("sentiment_neutral", Icons.Filled.SentimentNeutral, "Neutral"), + ProfileIcon("sentiment_satisfied", Icons.Filled.SentimentSatisfied, "Satisfied"), + ProfileIcon("sentiment_satisfied_alt", Icons.Filled.SentimentSatisfiedAlt, "Satisfied Alt"), + ProfileIcon( + "sentiment_very_dissatisfied", + Icons.Filled.SentimentVeryDissatisfied, + "Very Dissatisfied", + ), + ProfileIcon( + "sentiment_very_satisfied", + Icons.Filled.SentimentVerySatisfied, + "Very Satisfied", + ), + ProfileIcon("severe_cold", Icons.Filled.SevereCold, "Severe Cold"), + ProfileIcon("share", Icons.Filled.Share, "Share"), + ProfileIcon("sick", Icons.Filled.Sick, "Sick"), + ProfileIcon("sign_language", Icons.Filled.SignLanguage, "Sign Language"), + ProfileIcon("single_bed", Icons.Filled.SingleBed, "Single Bed"), + ProfileIcon("skateboarding", Icons.Filled.Skateboarding, "Skateboarding"), + ProfileIcon("sledding", Icons.Filled.Sledding, "Sledding"), + ProfileIcon("snowboarding", Icons.Filled.Snowboarding, "Snowboarding"), + ProfileIcon("snowshoeing", Icons.Filled.Snowshoeing, "Snowshoeing"), + ProfileIcon("social_distance", Icons.Filled.SocialDistance, "Social Distance"), + ProfileIcon("south_america", Icons.Filled.SouthAmerica, "South America"), + ProfileIcon("sports", Icons.Filled.Sports, "Sports"), + ProfileIcon("sports_baseball", Icons.Filled.SportsBaseball, "Baseball"), + ProfileIcon("sports_basketball", Icons.Filled.SportsBasketball, "Basketball"), + ProfileIcon("sports_cricket", Icons.Filled.SportsCricket, "Cricket"), + ProfileIcon("sports_esports", Icons.Filled.SportsEsports, "Esports"), + ProfileIcon("sports_football", Icons.Filled.SportsFootball, "Football"), + ProfileIcon("sports_golf", Icons.Filled.SportsGolf, "Golf"), + ProfileIcon("sports_gymnastics", Icons.Filled.SportsGymnastics, "Gymnastics"), + ProfileIcon("sports_handball", Icons.Filled.SportsHandball, "Handball"), + ProfileIcon("sports_hockey", Icons.Filled.SportsHockey, "Hockey"), + ProfileIcon("sports_kabaddi", Icons.Filled.SportsKabaddi, "Kabaddi"), + ProfileIcon("sports_martial_arts", Icons.Filled.SportsMartialArts, "Martial Arts"), + ProfileIcon("sports_mma", Icons.Filled.SportsMma, "MMA"), + ProfileIcon("sports_motorsports", Icons.Filled.SportsMotorsports, "Motorsports"), + ProfileIcon("sports_rugby", Icons.Filled.SportsRugby, "Rugby"), + ProfileIcon("sports_soccer", Icons.Filled.SportsSoccer, "Soccer"), + ProfileIcon("sports_tennis", Icons.Filled.SportsTennis, "Tennis"), + ProfileIcon("sports_volleyball", Icons.Filled.SportsVolleyball, "Volleyball"), + ProfileIcon("surfing", Icons.Filled.Surfing, "Surfing"), + ProfileIcon("switch_account", Icons.Filled.SwitchAccount, "Switch Account"), + ProfileIcon("thumb_down_alt", Icons.Filled.ThumbDownAlt, "Thumb Down Alt"), + ProfileIcon("thumb_up_alt", Icons.Filled.ThumbUpAlt, "Thumb Up Alt"), + ProfileIcon("thunderstorm", Icons.Filled.Thunderstorm, "Thunderstorm"), + ProfileIcon("tornado", Icons.Filled.Tornado, "Tornado"), + ProfileIcon("transgender", Icons.Filled.Transgender, "Transgender"), + ProfileIcon("travel_explore", Icons.Filled.TravelExplore, "Travel Explore"), + ProfileIcon("tsunami", Icons.Filled.Tsunami, "Tsunami"), + ProfileIcon("vaccines", Icons.Filled.Vaccines, "Vaccines"), + ProfileIcon("volcano", Icons.Filled.Volcano, "Volcano"), + ProfileIcon("wallet", Icons.Filled.Wallet, "Wallet"), + ProfileIcon("water_drop", Icons.Filled.WaterDrop, "Water Drop"), + ProfileIcon("waving_hand", Icons.Filled.WavingHand, "Waving Hand"), + // ProfileIcon("whatsapp", Icons.Filled.WhatsApp, "WhatsApp"), + ProfileIcon("whatshot", Icons.Filled.Whatshot, "Whatshot"), + ProfileIcon("woman", Icons.Filled.Woman, "Woman"), + ProfileIcon("woman_2", Icons.Filled.Woman2, "Woman 2"), + ProfileIcon("workspace_premium", Icons.Filled.WorkspacePremium, "Workspace Premium"), + ProfileIcon("workspaces", Icons.Filled.Workspaces, "Workspaces"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt new file mode 100644 index 0000000000..f147d2769e --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/compose/util/icons/ToggleIcons.kt @@ -0,0 +1,44 @@ +package io.nekohasekai.sfa.compose.util.icons + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckBox +import androidx.compose.material.icons.filled.CheckBoxOutlineBlank +import androidx.compose.material.icons.filled.IndeterminateCheckBox +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.filled.StarBorderPurple500 +import androidx.compose.material.icons.filled.StarHalf +import androidx.compose.material.icons.filled.StarOutline +import androidx.compose.material.icons.filled.StarPurple500 +import androidx.compose.material.icons.filled.ToggleOff +import androidx.compose.material.icons.filled.ToggleOn +import io.nekohasekai.sfa.compose.util.ProfileIcon + +/** + * Toggle category icons - Switches and toggles + * Based on Google's Material Design Icons taxonomy + */ +object ToggleIcons { + val icons = + listOf( + ProfileIcon("check_box", Icons.Filled.CheckBox, "Check Box"), + ProfileIcon( + "check_box_outline_blank", + Icons.Filled.CheckBoxOutlineBlank, + "Check Box Blank", + ), + ProfileIcon("indeterminate_check_box", Icons.Filled.IndeterminateCheckBox, "Indeterminate"), + ProfileIcon("radio_button_checked", Icons.Filled.RadioButtonChecked, "Radio Checked"), + ProfileIcon("radio_button_unchecked", Icons.Filled.RadioButtonUnchecked, "Radio Unchecked"), + ProfileIcon("star", Icons.Filled.Star, "Star"), + ProfileIcon("star_border", Icons.Filled.StarBorder, "Star Border"), + ProfileIcon("star_border_purple500", Icons.Filled.StarBorderPurple500, "Star Purple"), + ProfileIcon("star_half", Icons.Filled.StarHalf, "Star Half"), + ProfileIcon("star_outline", Icons.Filled.StarOutline, "Star Outline"), + ProfileIcon("star_purple500", Icons.Filled.StarPurple500, "Star Purple"), + ProfileIcon("toggle_off", Icons.Filled.ToggleOff, "Toggle Off"), + ProfileIcon("toggle_on", Icons.Filled.ToggleOn, "Toggle On"), + ) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt index c0bb9478fd..fc9e41e35b 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Action.kt @@ -4,4 +4,4 @@ object Action { const val SERVICE = "io.nekohasekai.sfa.SERVICE" const val SERVICE_CLOSE = "io.nekohasekai.sfa.SERVICE_CLOSE" const val OPEN_URL = "io.nekohasekai.sfa.SERVICE_OPEN_URL" -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt index ce0c41880b..d2c9882fd2 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Alert.kt @@ -7,5 +7,5 @@ enum class Alert { EmptyConfiguration, StartCommandServer, CreateService, - StartService -} \ No newline at end of file + StartService, +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt index 1dfc0c1e15..680c0fcef2 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Bugs.kt @@ -4,11 +4,11 @@ import android.os.Build import io.nekohasekai.sfa.BuildConfig object Bugs { - // TODO: remove launch after fixed // https://github.com/golang/go/issues/68760 - val fixAndroidStack = BuildConfig.DEBUG || - Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1 || + val fixAndroidStack = + BuildConfig.DEBUG || + Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + Build.VERSION.SDK_INT <= Build.VERSION_CODES.N_MR1 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.P - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt index 1bf8527dd7..fb70bf50bf 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/EnabledType.kt @@ -4,27 +4,22 @@ import android.content.Context import io.nekohasekai.sfa.R enum class EnabledType(val boolValue: Boolean) { - Enabled(true), Disabled(false); + Enabled(true), + Disabled(false), + ; - fun getString(context: Context): String { - return when (this) { - Enabled -> context.getString(R.string.enabled) - Disabled -> context.getString(R.string.disabled) - } + fun getString(context: Context): String = when (this) { + Enabled -> context.getString(R.string.enabled) + Disabled -> context.getString(R.string.disabled) } - companion object { - fun from(value: Boolean): EnabledType { - return if (value) Enabled else Disabled - } + fun from(value: Boolean): EnabledType = if (value) Enabled else Disabled - fun valueOf(context: Context, value: String): EnabledType { - return when (value) { - context.getString(R.string.enabled) -> Enabled - context.getString(R.string.disabled) -> Disabled - else -> Disabled - } + fun valueOf(context: Context, value: String): EnabledType = when (value) { + context.getString(R.string.enabled) -> Enabled + context.getString(R.string.disabled) -> Disabled + else -> Disabled } } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt index c731b6127a..e9d07a86e1 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Path.kt @@ -3,4 +3,4 @@ package io.nekohasekai.sfa.constant object Path { const val SETTINGS_DATABASE_PATH = "settings.db" const val PROFILES_DATABASE_PATH = "profiles.db" -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt deleted file mode 100644 index 00e8110b3a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/PerAppProxyUpdateType.kt +++ /dev/null @@ -1,41 +0,0 @@ -package io.nekohasekai.sfa.constant - -import android.content.Context -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.database.Settings - -enum class PerAppProxyUpdateType { - Disabled, Select, Deselect; - - fun value() = when (this) { - Disabled -> Settings.PER_APP_PROXY_DISABLED - Select -> Settings.PER_APP_PROXY_INCLUDE - Deselect -> Settings.PER_APP_PROXY_EXCLUDE - } - - fun getString(context: Context): String { - return when (this) { - Disabled -> context.getString(R.string.disabled) - Select -> context.getString(R.string.action_select) - Deselect -> context.getString(R.string.action_deselect) - } - } - - companion object { - fun valueOf(value: Int): PerAppProxyUpdateType = when (value) { - Settings.PER_APP_PROXY_DISABLED -> Disabled - Settings.PER_APP_PROXY_INCLUDE -> Select - Settings.PER_APP_PROXY_EXCLUDE -> Deselect - else -> throw IllegalArgumentException() - } - - fun valueOf(context: Context, value: String): PerAppProxyUpdateType { - return when (value) { - context.getString(R.string.disabled) -> Disabled - context.getString(R.string.action_select) -> Select - context.getString(R.string.action_deselect) -> Deselect - else -> Disabled - } - } - } -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt index 1bb0ad98d3..8b7f8c54e6 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/ServiceMode.kt @@ -3,4 +3,4 @@ package io.nekohasekai.sfa.constant object ServiceMode { const val NORMAL = "normal" const val VPN = "vpn" -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt index 25a7cc6a39..3109681a1d 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/SettingsKey.kt @@ -1,22 +1,39 @@ package io.nekohasekai.sfa.constant object SettingsKey { - const val SELECTED_PROFILE = "selected_profile" const val SERVICE_MODE = "service_mode" const val CHECK_UPDATE_ENABLED = "check_update_enabled" - const val DISABLE_MEMORY_LIMIT = "disable_memory_limit" + const val UPDATE_CHECK_PROMPTED = "update_check_prompted" + const val UPDATE_TRACK = "update_track" + const val SILENT_INSTALL_ENABLED = "silent_install_enabled" + const val SILENT_INSTALL_METHOD = "silent_install_method" + const val AUTO_UPDATE_ENABLED = "auto_update_enabled" const val DYNAMIC_NOTIFICATION = "dynamic_notification" + const val DISABLE_DEPRECATED_WARNINGS = "disable_deprecated_warnings" + const val AUTO_REDIRECT = "auto_redirect" const val PER_APP_PROXY_ENABLED = "per_app_proxy_enabled" const val PER_APP_PROXY_MODE = "per_app_proxy_mode" const val PER_APP_PROXY_LIST = "per_app_proxy_list" - const val PER_APP_PROXY_UPDATE_ON_CHANGE = "per_app_proxy_update_on_change" + const val PER_APP_PROXY_MANAGED_MODE = "per_app_proxy_managed_mode" + const val PER_APP_PROXY_MANAGED_LIST = "per_app_proxy_managed_list" + const val PER_APP_PROXY_PACKAGE_QUERY_MODE = "per_app_proxy_package_query_mode" const val SYSTEM_PROXY_ENABLED = "system_proxy_enabled" + const val PRIVILEGE_SETTINGS_ENABLED = "hide_settings_enabled" + const val PRIVILEGE_SETTINGS_LIST = "hide_settings_list" + const val PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED = "hide_settings_interface_rename_enabled" + const val PRIVILEGE_SETTINGS_INTERFACE_PREFIX = "hide_settings_interface_prefix" + + // dashboard + const val DASHBOARD_ITEM_ORDER = "dashboard_item_order" + const val DASHBOARD_DISABLED_ITEMS = "dashboard_disabled_items" + // cache - const val STARTED_BY_USER = "started_by_user" - -} \ No newline at end of file + const val CACHED_UPDATE_INFO = "cached_update_info" + const val CACHED_APK_PATH = "cached_apk_path" + const val LAST_SHOWN_UPDATE_VERSION = "last_shown_update_version" +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt index 740637f7fc..49d1da3e2a 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/constant/Status.kt @@ -5,4 +5,4 @@ enum class Status { Starting, Started, Stopping, -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt index 57c1d69b96..cdc3a74a64 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Profile.kt @@ -1,6 +1,7 @@ package io.nekohasekai.sfa.database import android.os.Parcelable +import androidx.room.ColumnInfo import androidx.room.Delete import androidx.room.Entity import androidx.room.Insert @@ -19,12 +20,11 @@ class Profile( @PrimaryKey(autoGenerate = true) var id: Long = 0L, var userOrder: Long = 0L, var name: String = "", - var typed: TypedProfile = TypedProfile() + @ColumnInfo(defaultValue = "NULL") var icon: String? = null, + var typed: TypedProfile = TypedProfile(), ) : Parcelable { - @androidx.room.Dao interface Dao { - @Insert fun insert(profile: Profile): Long @@ -54,8 +54,5 @@ class Profile( @Query("SELECT MAX(id) + 1 FROM profiles") fun nextFileID(): Long? - } - } - diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt index 24cbff0c6d..e7eee0f296 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileDatabase.kt @@ -2,12 +2,24 @@ package io.nekohasekai.sfa.database import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase @Database( - entities = [Profile::class], version = 1 + entities = [Profile::class], + version = 2, + exportSchema = true, ) abstract class ProfileDatabase : RoomDatabase() { - abstract fun profileDao(): Profile.Dao -} \ No newline at end of file + companion object { + val MIGRATION_1_2 = + object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + // Add icon column to profiles table with default value null + database.execSQL("ALTER TABLE profiles ADD COLUMN icon TEXT DEFAULT NULL") + } + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt index 61f1dc6bf5..b0e16434cd 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/ProfileManager.kt @@ -9,7 +9,6 @@ import kotlinx.coroutines.launch @Suppress("RedundantSuspendModifier") object ProfileManager { - private val callbacks = mutableListOf<() -> Unit>() fun registerCallback(callback: () -> Unit) { @@ -27,29 +26,26 @@ object ProfileManager { .databaseBuilder( Application.application, ProfileDatabase::class.java, - Path.PROFILES_DATABASE_PATH + Path.PROFILES_DATABASE_PATH, ) - .fallbackToDestructiveMigration() + .addMigrations(ProfileDatabase.MIGRATION_1_2) + .fallbackToDestructiveMigrationOnDowngrade() .enableMultiInstanceInvalidation() .setQueryExecutor { GlobalScope.launch { it.run() } } .build() } - suspend fun nextOrder(): Long { - return instance.profileDao().nextOrder() ?: 0 - } + suspend fun nextOrder(): Long = instance.profileDao().nextOrder() ?: 0 - suspend fun nextFileID(): Long { - return instance.profileDao().nextFileID() ?: 1 - } + suspend fun nextFileID(): Long = instance.profileDao().nextFileID() ?: 1 + suspend fun get(id: Long): Profile? = instance.profileDao().get(id) - suspend fun get(id: Long): Profile? { - return instance.profileDao().get(id) - } - - suspend fun create(profile: Profile): Profile { + suspend fun create(profile: Profile, andSelect: Boolean = false): Profile { profile.id = instance.profileDao().insert(profile) + if (andSelect) { + Settings.selectedProfile = profile.id + } for (callback in callbacks.toList()) { callback() } @@ -96,8 +92,5 @@ object ProfileManager { } } - suspend fun list(): List { - return instance.profileDao().list() - } - -} \ No newline at end of file + suspend fun list(): List = instance.profileDao().list() +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt index 6f5bb79be7..23b1d8b90c 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/Settings.kt @@ -1,7 +1,9 @@ package io.nekohasekai.sfa.database +import android.os.Build import androidx.room.Room import io.nekohasekai.sfa.Application +import io.nekohasekai.sfa.BuildConfig import io.nekohasekai.sfa.bg.ProxyService import io.nekohasekai.sfa.bg.VPNService import io.nekohasekai.sfa.constant.Path @@ -21,14 +23,13 @@ import org.json.JSONObject import java.io.File object Settings { - @OptIn(DelicateCoroutinesApi::class) private val instance by lazy { Application.application.getDatabasePath(Path.SETTINGS_DATABASE_PATH).parentFile?.mkdirs() Room.databaseBuilder( Application.application, KeyValueDatabase::class.java, - Path.SETTINGS_DATABASE_PATH + Path.SETTINGS_DATABASE_PATH, ).allowMainThreadQueries() .fallbackToDestructiveMigration() .enableMultiInstanceInvalidation() @@ -40,27 +41,77 @@ object Settings { var serviceMode by dataStore.string(SettingsKey.SERVICE_MODE) { ServiceMode.NORMAL } var startedByUser by dataStore.boolean(SettingsKey.STARTED_BY_USER) - var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { true } - var disableMemoryLimit by dataStore.boolean(SettingsKey.DISABLE_MEMORY_LIMIT) + var checkUpdateEnabled by dataStore.boolean(SettingsKey.CHECK_UPDATE_ENABLED) { false } + var updateCheckPrompted by dataStore.boolean(SettingsKey.UPDATE_CHECK_PROMPTED) { false } + var updateTrack by dataStore.string(SettingsKey.UPDATE_TRACK) { + val versionName = BuildConfig.VERSION_NAME.lowercase() + if (versionName.contains("-alpha") || + versionName.contains("-beta") || + versionName.contains("-rc") + ) { + "beta" + } else { + "stable" + } + } + var silentInstallEnabled by dataStore.boolean(SettingsKey.SILENT_INSTALL_ENABLED) { false } + var silentInstallMethod by dataStore.string(SettingsKey.SILENT_INSTALL_METHOD) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + "PACKAGE_INSTALLER" + } else { + "SHIZUKU" + } + } + var autoUpdateEnabled by dataStore.boolean(SettingsKey.AUTO_UPDATE_ENABLED) { false } var dynamicNotification by dataStore.boolean(SettingsKey.DYNAMIC_NOTIFICATION) { true } - + var disableDeprecatedWarnings by dataStore.boolean(SettingsKey.DISABLE_DEPRECATED_WARNINGS) { false } const val PER_APP_PROXY_DISABLED = 0 const val PER_APP_PROXY_EXCLUDE = 1 const val PER_APP_PROXY_INCLUDE = 2 + var autoRedirect by dataStore.boolean(SettingsKey.AUTO_REDIRECT) { false } var perAppProxyEnabled by dataStore.boolean(SettingsKey.PER_APP_PROXY_ENABLED) { false } var perAppProxyMode by dataStore.int(SettingsKey.PER_APP_PROXY_MODE) { PER_APP_PROXY_EXCLUDE } var perAppProxyList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_LIST) { emptySet() } - var perAppProxyUpdateOnChange by dataStore.int(SettingsKey.PER_APP_PROXY_UPDATE_ON_CHANGE) { PER_APP_PROXY_DISABLED } + var perAppProxyManagedMode by dataStore.boolean(SettingsKey.PER_APP_PROXY_MANAGED_MODE) { false } + var perAppProxyManagedList by dataStore.stringSet(SettingsKey.PER_APP_PROXY_MANAGED_LIST) { emptySet() } + + const val PACKAGE_QUERY_MODE_SHIZUKU = "SHIZUKU" + const val PACKAGE_QUERY_MODE_ROOT = "ROOT" + var perAppProxyPackageQueryMode by dataStore.string(SettingsKey.PER_APP_PROXY_PACKAGE_QUERY_MODE) { PACKAGE_QUERY_MODE_SHIZUKU } + + fun getEffectivePerAppProxyMode(): Int = if (perAppProxyManagedMode) { + PER_APP_PROXY_EXCLUDE + } else { + perAppProxyMode + } + + fun getEffectivePerAppProxyList(): Set = if (perAppProxyManagedMode) { + perAppProxyManagedList + } else { + perAppProxyList + } var systemProxyEnabled by dataStore.boolean(SettingsKey.SYSTEM_PROXY_ENABLED) { true } - fun serviceClass(): Class<*> { - return when (serviceMode) { - ServiceMode.VPN -> VPNService::class.java - else -> ProxyService::class.java - } + var privilegeSettingsEnabled by dataStore.boolean(SettingsKey.PRIVILEGE_SETTINGS_ENABLED) { false } + var privilegeSettingsList by dataStore.stringSet(SettingsKey.PRIVILEGE_SETTINGS_LIST) { emptySet() } + var privilegeSettingsInterfaceRenameEnabled by dataStore.boolean( + SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_RENAME_ENABLED, + ) { false } + var privilegeSettingsInterfacePrefix by dataStore.string(SettingsKey.PRIVILEGE_SETTINGS_INTERFACE_PREFIX) { "wlan" } + + var dashboardItemOrder by dataStore.string(SettingsKey.DASHBOARD_ITEM_ORDER) { "" } + var dashboardDisabledItems by dataStore.stringSet(SettingsKey.DASHBOARD_DISABLED_ITEMS) { emptySet() } + + 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 } + + fun serviceClass(): Class<*> = when (serviceMode) { + ServiceMode.VPN -> VPNService::class.java + else -> ProxyService::class.java } suspend fun rebuildServiceMode(): Boolean { @@ -92,5 +143,4 @@ object Settings { } return false } - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt index 6350b568ac..77c826f310 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/TypedProfile.kt @@ -11,15 +11,14 @@ import io.nekohasekai.sfa.ktx.unmarshall import java.util.Date class TypedProfile() : Parcelable { - enum class Type { - Local, Remote; + Local, + Remote, + ; - fun getString(context: Context): String { - return when (this) { - Local -> context.getString(R.string.profile_type_local) - Remote -> context.getString(R.string.profile_type_remote) - } + fun getString(context: Context): String = when (this) { + Local -> context.getString(R.string.profile_type_local) + Remote -> context.getString(R.string.profile_type_remote) } companion object { @@ -63,29 +62,19 @@ class TypedProfile() : Parcelable { writer.writeInt(autoUpdateInterval) } - override fun describeContents(): Int { - return 0 - } + override fun describeContents(): Int = 0 companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): TypedProfile { - return TypedProfile(parcel) - } + override fun createFromParcel(parcel: Parcel): TypedProfile = TypedProfile(parcel) - override fun newArray(size: Int): Array { - return arrayOfNulls(size) - } + override fun newArray(size: Int): Array = arrayOfNulls(size) } class Convertor { - @TypeConverter fun marshall(profile: TypedProfile) = profile.marshall() @TypeConverter - fun unmarshall(content: ByteArray) = - content.unmarshall(::TypedProfile) - + fun unmarshall(content: ByteArray) = content.unmarshall(::TypedProfile) } - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt index d94ad921ee..97102915ec 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueDatabase.kt @@ -4,10 +4,9 @@ import androidx.room.Database import androidx.room.RoomDatabase @Database( - entities = [KeyValueEntity::class], version = 1 + entities = [KeyValueEntity::class], + version = 1, ) abstract class KeyValueDatabase : RoomDatabase() { - abstract fun keyValuePairDao(): KeyValueEntity.Dao - } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt index 2389b35376..29c0500ffb 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/KeyValueEntity.kt @@ -22,20 +22,16 @@ class KeyValueEntity() : Parcelable { const val TYPE_STRING_SET = 5 @JvmField - val CREATOR = object : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel): KeyValueEntity { - return KeyValueEntity(parcel) - } + val CREATOR = + object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): KeyValueEntity = KeyValueEntity(parcel) - override fun newArray(size: Int): Array { - return arrayOfNulls(size) + override fun newArray(size: Int): Array = arrayOfNulls(size) } - } } @androidx.room.Dao interface Dao { - @Query("SELECT * FROM KeyValueEntity") fun all(): List @@ -71,16 +67,19 @@ class KeyValueEntity() : Parcelable { val string: String? get() = if (valueType == TYPE_STRING) String(value) else null val stringSet: Set? - get() = if (valueType == TYPE_STRING_SET) { - val buffer = ByteBuffer.wrap(value) - val result = HashSet() - while (buffer.hasRemaining()) { - val chArr = ByteArray(buffer.int) - buffer.get(chArr) - result.add(String(chArr)) + get() = + if (valueType == TYPE_STRING_SET) { + val buffer = ByteBuffer.wrap(value) + val result = HashSet() + while (buffer.hasRemaining()) { + val chArr = ByteArray(buffer.int) + buffer.get(chArr) + result.add(String(chArr)) + } + result + } else { + null } - result - } else null @Ignore constructor(key: String) : this() { @@ -126,16 +125,14 @@ class KeyValueEntity() : Parcelable { } @Suppress("IMPLICIT_CAST_TO_ANY") - override fun toString(): String { - return when (valueType) { - TYPE_BOOLEAN -> boolean - TYPE_FLOAT -> float - TYPE_LONG -> long - TYPE_STRING -> string - TYPE_STRING_SET -> stringSet - else -> null - }?.toString() ?: "null" - } + override fun toString(): String = when (valueType) { + TYPE_BOOLEAN -> boolean + TYPE_FLOAT -> float + TYPE_LONG -> long + TYPE_STRING -> string + TYPE_STRING_SET -> stringSet + else -> null + }?.toString() ?: "null" constructor(parcel: Parcel) : this() { key = parcel.readString()!! @@ -149,8 +146,5 @@ class KeyValueEntity() : Parcelable { parcel.writeByteArray(value) } - override fun describeContents(): Int { - return 0 - } - + override fun describeContents(): Int = 0 } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt index ac106939f1..c868e45a2f 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/database/preference/RoomPreferenceDataStore.kt @@ -3,35 +3,41 @@ package io.nekohasekai.sfa.database.preference import androidx.preference.PreferenceDataStore @Suppress("MemberVisibilityCanBePrivate", "unused") -open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : - PreferenceDataStore() { - +open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : PreferenceDataStore() { fun getBoolean(key: String) = kvPairDao[key]?.boolean + fun getFloat(key: String) = kvPairDao[key]?.float + fun getInt(key: String) = kvPairDao[key]?.long?.toInt() + fun getLong(key: String) = kvPairDao[key]?.long + fun getString(key: String) = kvPairDao[key]?.string + fun getStringSet(key: String) = kvPairDao[key]?.stringSet + fun reset() = kvPairDao.reset() override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue + override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue + override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue + override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue + override fun getString(key: String, defValue: String?) = getString(key) ?: defValue - override fun getStringSet(key: String, defValue: MutableSet?) = - getStringSet(key) ?: defValue - fun putBoolean(key: String, value: Boolean?) = - if (value == null) remove(key) else putBoolean(key, value) + override fun getStringSet(key: String, defValue: MutableSet?) = getStringSet(key) ?: defValue - fun putFloat(key: String, value: Float?) = - if (value == null) remove(key) else putFloat(key, value) + fun putBoolean(key: String, value: Boolean?) = if (value == null) remove(key) else putBoolean(key, value) - fun putInt(key: String, value: Int?) = - if (value == null) remove(key) else putLong(key, value.toLong()) + fun putFloat(key: String, value: Float?) = if (value == null) remove(key) else putFloat(key, value) + + fun putInt(key: String, value: Int?) = if (value == null) remove(key) else putLong(key, value.toLong()) fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value) + override fun putBoolean(key: String, value: Boolean) { kvPairDao.put(KeyValueEntity(key).put(value)) fireChangeListener(key) @@ -52,16 +58,19 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : fireChangeListener(key) } - override fun putString(key: String, value: String?) = if (value == null) remove(key) else { + override fun putString(key: String, value: String?) = if (value == null) { + remove(key) + } else { kvPairDao.put(KeyValueEntity(key).put(value)) fireChangeListener(key) } - override fun putStringSet(key: String, values: MutableSet?) = - if (values == null) remove(key) else { - kvPairDao.put(KeyValueEntity(key).put(values)) - fireChangeListener(key) - } + override fun putStringSet(key: String, values: MutableSet?) = if (values == null) { + remove(key) + } else { + kvPairDao.put(KeyValueEntity(key).put(values)) + fireChangeListener(key) + } fun remove(key: String) { kvPairDao.delete(key) @@ -69,10 +78,12 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : } private val listeners = HashSet() + private fun fireChangeListener(key: String) { - val listeners = synchronized(listeners) { - listeners.toList() - } + val listeners = + synchronized(listeners) { + listeners.toList() + } listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } } @@ -87,4 +98,4 @@ open class RoomPreferenceDataStore(private val kvPairDao: KeyValueEntity.Dao) : listeners.remove(listener) } } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt index 0c4786df0b..ffb86313ec 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Browsers.kt @@ -14,13 +14,13 @@ fun Context.launchCustomTab(link: String) { CustomTabsIntent.COLOR_SCHEME_LIGHT, CustomTabColorSchemeParams.Builder().apply { setToolbarColor(color) - }.build() + }.build(), ) setColorSchemeParams( CustomTabsIntent.COLOR_SCHEME_DARK, CustomTabColorSchemeParams.Builder().apply { setToolbarColor(color) - }.build() + }.build(), ) }.build().launchUrl(this, Uri.parse(link)) -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt index 48ee0f42cb..b5654eae24 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Clips.kt @@ -9,4 +9,4 @@ var clipboardText: String? if (plainText != null) { Application.clipboard.setPrimaryClip(ClipData.newPlainText(null, plainText)) } - } \ No newline at end of file + } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt index 17631bbb18..44fc46714b 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Colors.kt @@ -9,13 +9,8 @@ import androidx.annotation.ColorInt import androidx.core.content.ContextCompat import com.google.android.material.color.MaterialColors - @ColorInt -fun Context.getAttrColor( - @AttrRes attrColor: Int, - typedValue: TypedValue = TypedValue(), - resolveRefs: Boolean = true -): Int { +fun Context.getAttrColor(@AttrRes attrColor: Int, typedValue: TypedValue = TypedValue(), resolveRefs: Boolean = true): Int { theme.resolveAttribute(attrColor, typedValue, resolveRefs) return typedValue.data } @@ -44,4 +39,4 @@ fun colorForURLTestDelay(context: Context, urlTestDelay: Int): Int { } } return MaterialColors.harmonizeWithPrimary(context, ContextCompat.getColor(context, colorRes)) -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt index ac31b706b2..76a8b64e7b 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Context.kt @@ -4,6 +4,4 @@ import android.content.Context import android.content.pm.PackageManager import androidx.core.content.ContextCompat -fun Context.hasPermission(permission: String): Boolean { - return ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED -} +fun Context.hasPermission(permission: String): Boolean = ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt index aad9f83e3f..b1fe740e84 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Continuations.kt @@ -2,7 +2,6 @@ package io.nekohasekai.sfa.ktx import kotlin.coroutines.Continuation - fun Continuation.tryResume(value: T) { try { resumeWith(Result.success(value)) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt index 80a98662cd..591e1cc3c2 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dialogs.kt @@ -1,24 +1,46 @@ package io.nekohasekai.sfa.ktx +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast import androidx.annotation.StringRes import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sfa.R -fun Context.errorDialogBuilder(@StringRes messageId: Int): MaterialAlertDialogBuilder { - return MaterialAlertDialogBuilder(this) - .setTitle(R.string.error_title) - .setMessage(messageId) - .setPositiveButton(android.R.string.ok, null) -} +fun Context.errorDialogBuilder(@StringRes messageId: Int): MaterialAlertDialogBuilder = errorDialogBuilder(getString(messageId)) fun Context.errorDialogBuilder(message: String): MaterialAlertDialogBuilder { + val contentView = buildSelectableMessageView(message) return MaterialAlertDialogBuilder(this) .setTitle(R.string.error_title) - .setMessage(message) + .setView(contentView) + .setNeutralButton(R.string.per_app_proxy_action_copy) { _, _ -> + copyToClipboard(message) + } .setPositiveButton(android.R.string.ok, null) } -fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder { - return errorDialogBuilder(exception.localizedMessage ?: exception.toString()) -} \ No newline at end of file +fun Context.errorDialogBuilder(exception: Throwable): MaterialAlertDialogBuilder = errorDialogBuilder(exception.localizedMessage ?: exception.toString()) + +private fun Context.buildSelectableMessageView(message: String): ScrollView { + val density = resources.displayMetrics.density + val padding = (16 * density).toInt() + val textView = + TextView(this).apply { + text = message + setTextIsSelectable(true) + setPadding(padding, padding, padding, padding) + } + return ScrollView(this).apply { + addView(textView) + } +} + +private fun Context.copyToClipboard(text: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(getString(R.string.error_title), text)) + Toast.makeText(this, getString(R.string.copied_to_clipboard), Toast.LENGTH_SHORT).show() +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt index bb2dcebcf1..a5a4d46a3c 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Dimens.kt @@ -5,10 +5,6 @@ import kotlin.math.ceil private val density = Resources.getSystem().displayMetrics.density -fun dp2pxf(dpValue: Int): Float { - return density * dpValue -} +fun dp2pxf(dpValue: Int): Float = density * dpValue -fun dp2px(dpValue: Int): Int { - return ceil(dp2pxf(dpValue)).toInt() -} \ No newline at end of file +fun dp2px(dpValue: Int): Int = ceil(dp2pxf(dpValue)).toInt() diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt index 4116af759c..d7e0e2e7a6 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Inputs.kt @@ -18,7 +18,6 @@ var TextInputLayout.error: String editText?.error = value } - fun TextInputLayout.setSimpleItems(@ArrayRes redId: Int) { (editText as? MaterialAutoCompleteTextView)?.setSimpleItems(redId) } @@ -41,11 +40,10 @@ fun TextInputLayout.showErrorIfEmpty(): Boolean { return false } - fun TextInputLayout.addTextChangedListener(listener: (String) -> Unit) { addOnEditTextAttachedListener { editText?.addTextChangedListener { listener(it?.toString() ?: "") } } -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt index aea48075ae..224df75270 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Intents.kt @@ -6,9 +6,7 @@ import androidx.activity.result.ActivityResultLauncher import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.nekohasekai.sfa.R -fun Activity.startFilesForResult( - launcher: ActivityResultLauncher, input: String -) { +fun Activity.startFilesForResult(launcher: ActivityResultLauncher, input: String) { try { return launcher.launch(input) } catch (_: ActivityNotFoundException) { @@ -18,4 +16,4 @@ fun Activity.startFilesForResult( builder.setPositiveButton(resources.getString(android.R.string.ok), null) builder.setMessage(R.string.file_manager_missing) builder.show() -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt index f38effea51..bd0fcbd53b 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Preferences.kt @@ -3,60 +3,33 @@ package io.nekohasekai.sfa.ktx import androidx.preference.PreferenceDataStore import kotlin.reflect.KProperty -fun PreferenceDataStore.string( - name: String, - defaultValue: () -> String = { "" }, -) = PreferenceProxy(name, defaultValue, ::getString, ::putString) +fun PreferenceDataStore.string(name: String, defaultValue: () -> String = { "" }) = PreferenceProxy(name, defaultValue, ::getString, ::putString) -fun PreferenceDataStore.stringNotBlack( - name: String, - defaultValue: () -> String = { "" }, -) = PreferenceProxy(name, defaultValue, { key, default -> +fun PreferenceDataStore.stringNotBlack(name: String, defaultValue: () -> String = { "" }) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, default)?.takeIf { it.isNotBlank() } ?: default }, { key, value -> putString(key, value.takeIf { it.isNotBlank() } ?: defaultValue()) }) -fun PreferenceDataStore.boolean( - name: String, - defaultValue: () -> Boolean = { false }, -) = PreferenceProxy(name, defaultValue, ::getBoolean, ::putBoolean) +fun PreferenceDataStore.boolean(name: String, defaultValue: () -> Boolean = { false }) = PreferenceProxy(name, defaultValue, ::getBoolean, ::putBoolean) -fun PreferenceDataStore.int( - name: String, - defaultValue: () -> Int = { 0 }, -) = PreferenceProxy(name, defaultValue, ::getInt, ::putInt) +fun PreferenceDataStore.int(name: String, defaultValue: () -> Int = { 0 }) = PreferenceProxy(name, defaultValue, ::getInt, ::putInt) -fun PreferenceDataStore.stringToInt( - name: String, - defaultValue: () -> Int = { 0 }, -) = PreferenceProxy(name, defaultValue, { key, default -> +fun PreferenceDataStore.stringToInt(name: String, defaultValue: () -> Int = { 0 }) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.toIntOrNull() ?: default }, { key, value -> putString(key, "$value") }) -fun PreferenceDataStore.stringToIntIfExists( - name: String, - defaultValue: () -> Int = { 0 }, -) = PreferenceProxy(name, defaultValue, { key, default -> +fun PreferenceDataStore.stringToIntIfExists(name: String, defaultValue: () -> Int = { 0 }) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.toIntOrNull() ?: default }, { key, value -> putString(key, value.takeIf { it > 0 }?.toString() ?: "") }) -fun PreferenceDataStore.long( - name: String, - defaultValue: () -> Long = { 0L }, -) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong) +fun PreferenceDataStore.long(name: String, defaultValue: () -> Long = { 0L }) = PreferenceProxy(name, defaultValue, ::getLong, ::putLong) -fun PreferenceDataStore.stringToLong( - name: String, - defaultValue: () -> Long = { 0L }, -) = PreferenceProxy(name, defaultValue, { key, default -> +fun PreferenceDataStore.stringToLong(name: String, defaultValue: () -> Long = { 0L }) = PreferenceProxy(name, defaultValue, { key, default -> getString(key, "$default")?.toLongOrNull() ?: default }, { key, value -> putString(key, "$value") }) -fun PreferenceDataStore.stringSet( - name: String, - defaultValue: () -> Set = { emptySet() } -) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) +fun PreferenceDataStore.stringSet(name: String, defaultValue: () -> Set = { emptySet() }) = PreferenceProxy(name, defaultValue, ::getStringSet, ::putStringSet) class PreferenceProxy( val name: String, @@ -64,8 +37,7 @@ class PreferenceProxy( val getter: (String, T) -> T?, val setter: (String, value: T) -> Unit, ) { - operator fun setValue(thisObj: Any?, property: KProperty<*>, value: T) = setter(name, value) - operator fun getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue())!! -} \ No newline at end of file + operator fun getValue(thisObj: Any?, property: KProperty<*>) = getter(name, defaultValue())!! +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt index f8c672f251..54635fb690 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Room.kt @@ -18,4 +18,4 @@ fun ByteArray.unmarshall(constructor: (Parcel) -> T): T { val result = constructor(parcel) parcel.recycle() return result -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt index 52dc78e0b8..5e50613b1a 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Shares.kt @@ -2,21 +2,14 @@ package io.nekohasekai.sfa.ktx import android.content.Context import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Color import androidx.core.content.FileProvider -import androidx.fragment.app.FragmentActivity -import com.google.android.material.R -import com.google.zxing.BarcodeFormat -import com.google.zxing.qrcode.QRCodeWriter -import io.nekohasekai.libbox.Libbox import io.nekohasekai.libbox.ProfileContent import io.nekohasekai.sfa.database.Profile import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.ui.shared.QRCodeDialog import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File +import androidx.appcompat.R as AppCompatR suspend fun Context.shareProfile(profile: Profile) { val content = ProfileContent() @@ -46,32 +39,25 @@ suspend fun Context.shareProfile(profile: Profile) { Intent(Intent.ACTION_SEND).setType("application/octet-stream") .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .putExtra(Intent.EXTRA_STREAM, uri), - getString(R.string.abc_shareactionprovider_share_with) - ) + getString(AppCompatR.string.abc_shareactionprovider_share_with), + ), ) } } -fun FragmentActivity.shareProfileURL(profile: Profile) { - val link = Libbox.generateRemoteProfileImportLink( - profile.name, - profile.typed.remoteURL - ) - val imageSize = dp2px(256) - val color = getAttrColor(com.google.android.material.R.attr.colorPrimary) - val image = QRCodeWriter().encode(link, BarcodeFormat.QR_CODE, imageSize, imageSize, null) - val imageWidth = image.width - val imageHeight = image.height - val imageArray = IntArray(imageWidth * imageHeight) - for (y in 0 until imageHeight) { - val offset = y * imageWidth - for (x in 0 until imageWidth) { - imageArray[offset + x] = if (image.get(x, y)) color else Color.TRANSPARENT - - } +suspend fun Context.shareProfileAsJson(profile: Profile) { + val configDirectory = File(cacheDir, "share").also { it.mkdirs() } + val jsonFile = File(configDirectory, "${profile.name}.json") + jsonFile.writeText(File(profile.typed.path).readText()) + val uri = FileProvider.getUriForFile(this, "$packageName.cache", jsonFile) + withContext(Dispatchers.Main) { + startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("application/json") + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra(Intent.EXTRA_STREAM, uri), + getString(AppCompatR.string.abc_shareactionprovider_share_with), + ), + ) } - val bitmap = Bitmap.createBitmap(imageWidth, imageHeight, Bitmap.Config.ARGB_8888) - bitmap.setPixels(imageArray, 0, imageSize, 0, 0, imageWidth, imageHeight) - val dialog = QRCodeDialog(bitmap) - dialog.show(supportFragmentManager, "share-profile-url") -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt index bd8b2f6d50..1dbf3b5b42 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ktx/Wrappers.kt @@ -3,10 +3,14 @@ package io.nekohasekai.sfa.ktx import android.net.IpPrefix import android.os.Build import androidx.annotation.RequiresApi +import io.nekohasekai.libbox.ConnectionIterator +import io.nekohasekai.libbox.LogEntry +import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.RoutePrefix import io.nekohasekai.libbox.StringBox import io.nekohasekai.libbox.StringIterator import java.net.InetAddress +import io.nekohasekai.libbox.Connection as LibboxConnection val StringBox?.unwrap: String get() { @@ -23,23 +27,29 @@ fun Iterable.toStringIterator(): StringIterator { return 0 } - override fun hasNext(): Boolean { - return iterator.hasNext() - } + override fun hasNext(): Boolean = iterator.hasNext() - override fun next(): String { - return iterator.next() - } + override fun next(): String = iterator.next() } } -fun StringIterator.toList(): List { - return mutableListOf().apply { - while (hasNext()) { - add(next()) - } +fun StringIterator.toList(): List = mutableListOf().apply { + while (hasNext()) { + add(next()) + } +} + +fun LogIterator.toList(): List = mutableListOf().apply { + while (hasNext()) { + add(next()) } } @RequiresApi(Build.VERSION_CODES.TIRAMISU) -fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix()) \ No newline at end of file +fun RoutePrefix.toIpPrefix() = IpPrefix(InetAddress.getByName(address()), prefix()) + +fun ConnectionIterator.toList(): List = mutableListOf().apply { + while (hasNext()) { + add(next()) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt new file mode 100644 index 0000000000..d222c87fb0 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/ByteArrayExtensions.kt @@ -0,0 +1,20 @@ +package io.nekohasekai.sfa.qrs + +fun ByteArray.readIntLE(offset: Int): Int = (this[offset].toInt() and 0xFF) or + ((this[offset + 1].toInt() and 0xFF) shl 8) or + ((this[offset + 2].toInt() and 0xFF) shl 16) or + ((this[offset + 3].toInt() and 0xFF) shl 24) + +fun ByteArray.writeIntLE(offset: Int, value: Int) { + this[offset] = value.toByte() + this[offset + 1] = (value shr 8).toByte() + this[offset + 2] = (value shr 16).toByte() + this[offset + 3] = (value shr 24).toByte() +} + +fun ByteArray.writeIntBE(offset: Int, value: Int) { + this[offset] = (value shr 24).toByte() + this[offset + 1] = (value shr 16).toByte() + this[offset + 2] = (value shr 8).toByte() + this[offset + 3] = value.toByte() +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt new file mode 100644 index 0000000000..4eed13be85 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/LubyCodec.kt @@ -0,0 +1,321 @@ +package io.nekohasekai.sfa.qrs + +import java.util.zip.CRC32 +import kotlin.random.Random + +class LubyCodec(private val sliceSize: Int = QRSConstants.DEFAULT_SLICE_SIZE) { + internal class IntArrayKey(val indices: IntArray) { + private val hash = indices.contentHashCode() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is IntArrayKey) return false + return indices.contentEquals(other.indices) + } + + override fun hashCode(): Int = hash + } + + data class EncodedBlock( + val degree: Int, + val indices: IntArray, + val totalBlocks: Int, + val compressedSize: Int, + val checksum: Long, + val data: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as EncodedBlock + return degree == other.degree && + indices.contentEquals(other.indices) && + totalBlocks == other.totalBlocks && + compressedSize == other.compressedSize && + checksum == other.checksum && + data.contentEquals(other.data) + } + + override fun hashCode(): Int { + var result = degree + result = 31 * result + indices.contentHashCode() + result = 31 * result + totalBlocks + result = 31 * result + compressedSize + result = 31 * result + checksum.hashCode() + result = 31 * result + data.contentHashCode() + return result + } + } + + class DecodingState(val totalBlocks: Int, val compressedSize: Int, val checksum: Long) { + val decodedBlocks: Array = arrayOfNulls(totalBlocks) + var decodedCount: Int = 0 + + internal val blockKeyMap: MutableMap = mutableMapOf() + internal val blockSubkeyMap: MutableMap> = mutableMapOf() + val blockIndexMap: MutableMap> = mutableMapOf() + val blockDisposeMap: MutableMap Unit>> = mutableMapOf() + + class PendingBlock(var indices: MutableList, var data: ByteArray) + } + + fun encode(originalData: ByteArray, compressedData: ByteArray, compressedSize: Int): Sequence = sequence { + val k = (compressedData.size + sliceSize - 1) / sliceSize + if (k == 0) return@sequence + + val paddedData = compressedData.copyOf(k * sliceSize) + val blocks = (0 until k).map { i -> + paddedData.copyOfRange(i * sliceSize, (i + 1) * sliceSize) + } + + val crc = CRC32() + crc.update(originalData) + // Official: (raw_crc ^ k ^ 0xFFFFFFFF) + // Java CRC32.getValue() = raw_crc ^ 0xFFFFFFFF + // So: official = getValue() ^ 0xFFFFFFFF ^ k ^ 0xFFFFFFFF = getValue() ^ k + val checksum = (crc.value xor k.toLong()) and 0xFFFFFFFFL + + var seed = 0L + while (true) { + val random = Random(seed++) + val degree = SolitonDistribution.sample(k, random) + val indices = selectIndices(k, degree, random) + val blockData = xorBlocks(blocks, indices) + + yield( + EncodedBlock( + degree = degree, + indices = indices, + totalBlocks = k, + compressedSize = compressedSize, + checksum = checksum, + data = blockData, + ), + ) + } + } + + fun createDecodingState(firstBlock: EncodedBlock): DecodingState = DecodingState( + totalBlocks = firstBlock.totalBlocks, + compressedSize = firstBlock.compressedSize, + checksum = firstBlock.checksum, + ) + + fun processBlock(state: DecodingState, block: EncodedBlock): Boolean { + val queue = ArrayDeque() + queue.add(DecodingState.PendingBlock(block.indices.sorted().toMutableList(), block.data.clone())) + + while (queue.isNotEmpty()) { + val pending = queue.removeFirst() + processPendingBlock(state, pending, queue) + } + + return state.decodedCount == state.totalBlocks + } + + private fun processPendingBlock( + state: DecodingState, + pending: DecodingState.PendingBlock, + queue: ArrayDeque, + ) { + var indices = pending.indices + val data = pending.data + + val key = indicesToKey(indices) + if (state.blockKeyMap.containsKey(key) || indices.all { state.decodedBlocks[it] != null }) { + return + } + + // XOR with already decoded blocks + if (indices.size > 1) { + val toRemove = mutableListOf() + for (idx in indices) { + state.decodedBlocks[idx]?.let { + xorInPlace(data, it) + toRemove.add(idx) + } + } + if (toRemove.isNotEmpty()) { + indices.removeAll(toRemove) + } + } + + // Try subset lookup: [1,2,3] XOR [1,2] = [3] + if (indices.size > 2) { + for (i in indices.indices) { + val subkey = indicesToKey(indices.filterIndexed { j, _ -> j != i }) + state.blockKeyMap[subkey]?.let { subblock -> + xorInPlace(data, subblock.data) + indices = mutableListOf(indices[i]) + pending.indices = indices + return@let + } + } + } + + // Still pending: store and register for future matching + if (indices.size > 1) { + val newKey = indicesToKey(indices) + state.blockKeyMap[newKey] = pending + + // Register for single-index lookups + for (idx in indices) { + state.blockIndexMap.getOrPut(idx) { mutableSetOf() }.add(pending) + } + + // Register subkeys for superset matching (degree > 2) + if (indices.size > 2) { + for (i in indices.indices) { + val subkey = indicesToKey(indices.filterIndexed { j, _ -> j != i }) + val dispose: () -> Unit = { state.blockSubkeyMap[subkey]?.remove(pending) } + state.blockSubkeyMap.getOrPut(subkey) { mutableSetOf() }.add(pending) + state.blockDisposeMap.getOrPut(indices[i]) { mutableListOf() }.add(dispose) + } + } + + // Check if this block can help decode any supersets + state.blockSubkeyMap[newKey]?.let { supersets -> + state.blockSubkeyMap.remove(newKey) + for (superblock in supersets.toList()) { + // Remove old registrations before modifying + val oldKey = indicesToKey(superblock.indices) + state.blockKeyMap.remove(oldKey) + for (idx in superblock.indices) { + state.blockIndexMap[idx]?.remove(superblock) + } + + xorInPlace(superblock.data, data) + superblock.indices.removeAll(indices) + + // Re-process through queue + queue.add(superblock) + } + } + } else if (indices.size == 1) { + val idx = indices[0] + if (state.decodedBlocks[idx] == null) { + state.decodedBlocks[idx] = data + state.decodedCount++ + propagateDecoding(state, idx, queue) + } + } + } + + private fun indicesToKey(indices: List): IntArrayKey = IntArrayKey(indices.sorted().toIntArray()) + + private fun propagateDecoding(state: DecodingState, decodedIdx: Int, queue: ArrayDeque) { + val toProcess = ArrayDeque() + toProcess.add(decodedIdx) + + while (toProcess.isNotEmpty()) { + val idx = toProcess.removeFirst() + val decodedData = state.decodedBlocks[idx] ?: continue + + // Dispose subkey registrations for this index + state.blockDisposeMap.remove(idx)?.forEach { it() } + + // Find and process blocks containing this index + val blocks = state.blockIndexMap.remove(idx) ?: continue + for (pending in blocks) { + val oldKey = indicesToKey(pending.indices) + state.blockKeyMap.remove(oldKey) + + xorInPlace(pending.data, decodedData) + pending.indices.remove(idx) + + // Remove from other index maps + for (otherIdx in pending.indices) { + state.blockIndexMap[otherIdx]?.remove(pending) + } + + if (pending.indices.size == 1) { + val newIdx = pending.indices[0] + if (state.decodedBlocks[newIdx] == null) { + state.decodedBlocks[newIdx] = pending.data + state.decodedCount++ + toProcess.add(newIdx) + } + } else if (pending.indices.size > 1) { + // Re-process through queue to properly update all registrations + queue.add(pending) + } + } + } + } + + fun assembleData(state: DecodingState): ByteArray { + val result = ByteArray(state.totalBlocks * sliceSize) + for (i in state.decodedBlocks.indices) { + state.decodedBlocks[i]?.copyInto(result, i * sliceSize) + } + return result + } + + fun verifyChecksum(originalData: ByteArray, expectedChecksum: Long, k: Int): Boolean { + val crc = CRC32() + crc.update(originalData) + // Official: (raw_crc ^ k ^ 0xFFFFFFFF) + // Java CRC32.getValue() = raw_crc ^ 0xFFFFFFFF + // So: official = getValue() ^ 0xFFFFFFFF ^ k ^ 0xFFFFFFFF = getValue() ^ k + val computed = (crc.value xor k.toLong()) and 0xFFFFFFFFL + return computed == expectedChecksum + } + + private fun selectIndices(k: Int, degree: Int, random: Random): IntArray { + val indices = (0 until k).shuffled(random).take(degree.coerceAtMost(k)) + return indices.toIntArray() + } + + private fun xorBlocks(blocks: List, indices: IntArray): ByteArray { + val result = blocks[indices[0]].clone() + for (i in 1 until indices.size) { + xorInPlace(result, blocks[indices[i]]) + } + return result + } + + private fun xorInPlace(dest: ByteArray, src: ByteArray) { + val len = minOf(dest.size, src.size) + var i = 0 + + // Process 8 bytes at a time using Long + while (i + 7 < len) { + val destLong = ((dest[i].toLong() and 0xFF) shl 56) or + ((dest[i + 1].toLong() and 0xFF) shl 48) or + ((dest[i + 2].toLong() and 0xFF) shl 40) or + ((dest[i + 3].toLong() and 0xFF) shl 32) or + ((dest[i + 4].toLong() and 0xFF) shl 24) or + ((dest[i + 5].toLong() and 0xFF) shl 16) or + ((dest[i + 6].toLong() and 0xFF) shl 8) or + (dest[i + 7].toLong() and 0xFF) + + val srcLong = ((src[i].toLong() and 0xFF) shl 56) or + ((src[i + 1].toLong() and 0xFF) shl 48) or + ((src[i + 2].toLong() and 0xFF) shl 40) or + ((src[i + 3].toLong() and 0xFF) shl 32) or + ((src[i + 4].toLong() and 0xFF) shl 24) or + ((src[i + 5].toLong() and 0xFF) shl 16) or + ((src[i + 6].toLong() and 0xFF) shl 8) or + (src[i + 7].toLong() and 0xFF) + + val result = destLong xor srcLong + + dest[i] = (result shr 56).toByte() + dest[i + 1] = (result shr 48).toByte() + dest[i + 2] = (result shr 40).toByte() + dest[i + 3] = (result shr 32).toByte() + dest[i + 4] = (result shr 24).toByte() + dest[i + 5] = (result shr 16).toByte() + dest[i + 6] = (result shr 8).toByte() + dest[i + 7] = result.toByte() + + i += 8 + } + + // Process remaining bytes + while (i < len) { + dest[i] = (dest[i].toInt() xor src[i].toInt()).toByte() + i++ + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt new file mode 100644 index 0000000000..8893873461 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSConstants.kt @@ -0,0 +1,25 @@ +package io.nekohasekai.sfa.qrs + +object QRSConstants { + const val OFFICIAL_URL_PREFIX = "https://qrss.netlify.app/#" + + const val DEFAULT_FRAME_COUNT = 200 + const val BITMAP_BUFFER_SIZE = 30 + const val RECOVERY_FACTOR = 1.3 + + // FPS settings + const val DEFAULT_FPS = 10 + const val MIN_FPS = 1 + const val MAX_FPS = 60 + + // Slice Size settings + const val DEFAULT_SLICE_SIZE = 500 + const val MIN_SLICE_SIZE = 100 + const val MAX_SLICE_SIZE = 1500 + + fun calculateRequiredFrames(dataSize: Int, sliceSize: Int): Int { + val k = (dataSize + sliceSize - 1) / sliceSize + if (k == 0) return 1 + return (k * RECOVERY_FACTOR).toInt().coerceAtLeast(k + 5) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt new file mode 100644 index 0000000000..c33c8bb57d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSDecoder.kt @@ -0,0 +1,181 @@ +package io.nekohasekai.sfa.qrs + +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.zip.Inflater + +class QRSDecoder { + private var codec: LubyCodec? = null + private var state: LubyCodec.DecodingState? = null + private val processedHashes = mutableSetOf() + + private val inflater = Inflater() + private val decompressBuffer = ByteArray(8192) + private val outputBuffer = ByteArrayOutputStream(32768) + + data class DecodeProgress( + val decodedBlocks: Int, + val totalBlocks: Int, + val isComplete: Boolean, + val data: ByteArray? = null, + val error: String? = null, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DecodeProgress + return decodedBlocks == other.decodedBlocks && + totalBlocks == other.totalBlocks && + isComplete == other.isComplete && + data.contentEquals(other.data) && + error == other.error + } + + override fun hashCode(): Int { + var result = decodedBlocks + result = 31 * result + totalBlocks + result = 31 * result + isComplete.hashCode() + result = 31 * result + (data?.contentHashCode() ?: 0) + result = 31 * result + (error?.hashCode() ?: 0) + return result + } + } + + @Synchronized + fun processFrame(base64Content: String): DecodeProgress? { + val payload = try { + Base64.getDecoder().decode(base64Content) + } catch (e: Exception) { + return null + } + return processFrame(payload) + } + + @Synchronized + fun processFrame(payload: ByteArray): DecodeProgress? { + val hash = payload.contentHashCode() + if (hash in processedHashes) { + return state?.let { + DecodeProgress(it.decodedCount, it.totalBlocks, it.decodedCount == it.totalBlocks) + } + } + processedHashes.add(hash) + + val block = parsePayload(payload) ?: return null + + // Auto-detect dataset switch: if checksum changes, reset decoder + if (state != null && state!!.checksum != block.checksum) { + reset() + } + + if (codec == null) { + codec = LubyCodec(sliceSize = block.data.size) + state = codec!!.createDecodingState(block) + } + + val currentState = state!! + val complete = codec!!.processBlock(currentState, block) + + return if (complete) { + val assembledData = codec!!.assembleData(currentState) + val compressedData = assembledData.copyOf(currentState.compressedSize) + + val decompressedData = try { + decompress(compressedData) + } catch (e: Exception) { + null + } + + if (decompressedData != null) { + val checksumValid = codec!!.verifyChecksum( + decompressedData, + currentState.checksum, + currentState.totalBlocks, + ) + if (checksumValid) { + return DecodeProgress( + currentState.decodedCount, + currentState.totalBlocks, + true, + decompressedData, + ) + } + } + + val rawChecksumValid = codec!!.verifyChecksum( + compressedData, + currentState.checksum, + currentState.totalBlocks, + ) + if (rawChecksumValid) { + DecodeProgress(currentState.decodedCount, currentState.totalBlocks, true, compressedData) + } else { + DecodeProgress( + currentState.decodedCount, + currentState.totalBlocks, + true, + error = "Checksum verification failed", + ) + } + } else { + DecodeProgress(currentState.decodedCount, currentState.totalBlocks, false) + } + } + + @Synchronized + fun reset() { + codec = null + state = null + processedHashes.clear() + } + + val progress: DecodeProgress? + @Synchronized get() = state?.let { + DecodeProgress(it.decodedCount, it.totalBlocks, it.decodedCount == it.totalBlocks) + } + + private fun parsePayload(payload: ByteArray): LubyCodec.EncodedBlock? { + if (payload.size < 16) return null + + var offset = 0 + val degree = payload.readIntLE(offset) + offset += 4 + + if (degree <= 0 || payload.size < 4 + 4 * degree + 12) return null + + val indices = IntArray(degree) { + val idx = payload.readIntLE(offset) + offset += 4 + idx + } + + val totalBlocks = payload.readIntLE(offset) + offset += 4 + + val compressedSize = payload.readIntLE(offset) + offset += 4 + + val checksum = payload.readIntLE(offset).toLong() and 0xFFFFFFFFL + offset += 4 + + if (offset > payload.size) return null + + val data = payload.copyOfRange(offset, payload.size) + + return LubyCodec.EncodedBlock(degree, indices, totalBlocks, compressedSize, checksum, data) + } + + private fun decompress(data: ByteArray): ByteArray { + inflater.reset() + inflater.setInput(data) + outputBuffer.reset() + + while (!inflater.finished()) { + val count = inflater.inflate(decompressBuffer) + if (count == 0 && inflater.needsInput()) break + outputBuffer.write(decompressBuffer, 0, count) + } + + return outputBuffer.toByteArray() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt new file mode 100644 index 0000000000..bd19bca20b --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/QRSEncoder.kt @@ -0,0 +1,108 @@ +package io.nekohasekai.sfa.qrs + +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.zip.Deflater + +class QRSEncoder(private val sliceSize: Int = QRSConstants.DEFAULT_SLICE_SIZE) { + private val codec = LubyCodec(sliceSize) + + companion object { + fun appendFileHeaderMeta(data: ByteArray, filename: String? = null, contentType: String? = null): ByteArray { + val meta = buildString { + append("{") + var hasContent = false + filename?.let { + append("\"filename\":\"") + append(escapeJson(it)) + append("\"") + hasContent = true + } + contentType?.let { + if (hasContent) append(",") + append("\"contentType\":\"") + append(escapeJson(it)) + append("\"") + } + append("}") + } + val metaBytes = meta.toByteArray(Charsets.ISO_8859_1) + + val result = ByteArray(4 + metaBytes.size + 4 + data.size) + var offset = 0 + + result.writeIntBE(offset, metaBytes.size) + offset += 4 + metaBytes.copyInto(result, offset) + offset += metaBytes.size + + result.writeIntBE(offset, data.size) + offset += 4 + data.copyInto(result, offset) + + return result + } + + private fun escapeJson(s: String): String = s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + data class QRSFrame(val content: String, val frameIndex: Int, val totalBlocks: Int) + + fun encode(data: ByteArray, urlPrefix: String = ""): Sequence { + val compressed = compress(data) + + return codec.encode(data, compressed, compressed.size).mapIndexed { index, block -> + val payload = buildPayload(block) + val base64 = Base64.getEncoder().encodeToString(payload) + QRSFrame("$urlPrefix$base64", index, block.totalBlocks) + } + } + + private fun compress(data: ByteArray): ByteArray { + val deflater = Deflater(Deflater.DEFAULT_COMPRESSION) + deflater.setInput(data) + deflater.finish() + + val outputStream = ByteArrayOutputStream(data.size) + val buffer = ByteArray(1024) + + while (!deflater.finished()) { + val count = deflater.deflate(buffer) + outputStream.write(buffer, 0, count) + } + + deflater.end() + return outputStream.toByteArray() + } + + private fun buildPayload(block: LubyCodec.EncodedBlock): ByteArray { + val headerSize = 4 + 4 * block.indices.size + 4 + 4 + 4 + val payload = ByteArray(headerSize + block.data.size) + var offset = 0 + + payload.writeIntLE(offset, block.degree) + offset += 4 + + for (idx in block.indices) { + payload.writeIntLE(offset, idx) + offset += 4 + } + + payload.writeIntLE(offset, block.totalBlocks) + offset += 4 + + payload.writeIntLE(offset, block.compressedSize) + offset += 4 + + payload.writeIntLE(offset, block.checksum.toInt()) + offset += 4 + + block.data.copyInto(payload, offset) + + return payload + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt new file mode 100644 index 0000000000..d588aa4245 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/qrs/SolitonDistribution.kt @@ -0,0 +1,19 @@ +package io.nekohasekai.sfa.qrs + +import kotlin.random.Random + +object SolitonDistribution { + fun sample(k: Int, random: Random): Int { + if (k <= 0) return 1 + + val p = random.nextDouble() + var cdf = 1.0 / k + if (p < cdf) return 1 + + for (d in 2..k) { + cdf += 1.0 / (d * (d - 1)) + if (p < cdf) return d + } + return k + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt deleted file mode 100644 index 319157f3c0..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/MainActivity.kt +++ /dev/null @@ -1,435 +0,0 @@ -package io.nekohasekai.sfa.ui - -import android.Manifest -import android.annotation.SuppressLint -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.net.VpnService -import android.os.Build -import android.os.Bundle -import android.text.Html -import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.lifecycleScope -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import androidx.navigation.fragment.NavHostFragment -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.navigateUp -import androidx.navigation.ui.setupActionBarWithNavController -import androidx.navigation.ui.setupWithNavController -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.ProfileContent -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.ServiceConnection -import io.nekohasekai.sfa.bg.ServiceNotification -import io.nekohasekai.sfa.constant.Action -import io.nekohasekai.sfa.constant.Alert -import io.nekohasekai.sfa.constant.ServiceMode -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.databinding.ActivityMainBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.hasPermission -import io.nekohasekai.sfa.ktx.launchCustomTab -import io.nekohasekai.sfa.ui.profile.NewProfileActivity -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.utils.MIUIUtils -import io.nekohasekai.sfa.vendor.Vendor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.util.Date - -class MainActivity : AbstractActivity(), - ServiceConnection.Callback { - - companion object { - private const val TAG = "MainActivity" - } - - private lateinit var navHostFragment: NavHostFragment - private lateinit var navController: NavController - private lateinit var appBarConfiguration: AppBarConfiguration - - private val connection = ServiceConnection(this, this) - - val serviceStatus = MutableLiveData(Status.Stopped) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - navHostFragment = - supportFragmentManager.findFragmentById(R.id.nav_host_fragment_activity_my) as NavHostFragment - navController = navHostFragment.navController - navController.setGraph(R.navigation.mobile_navigation) - navController.addOnDestinationChangedListener(::onDestinationChanged) - appBarConfiguration = - AppBarConfiguration( - setOf( - R.id.navigation_dashboard, - R.id.navigation_log, - R.id.navigation_configuration, - R.id.navigation_settings, - ) - ) - setupActionBarWithNavController(navController, appBarConfiguration) - binding.navView.setupWithNavController(navController) - reconnect() - startIntegration() - - onNewIntent(intent) - } - - override fun onSupportNavigateUp(): Boolean { - return navController.navigateUp(appBarConfiguration) - } - - @Suppress("UNUSED_PARAMETER") - private fun onDestinationChanged( - navController: NavController, - navDestination: NavDestination, - bundle: Bundle? - ) { - val destinationId = navDestination.id - binding.dashboardTabContainer.isVisible = destinationId == R.id.navigation_dashboard - } - - override public fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - val uri = intent.data ?: return - when (intent.action) { - Action.OPEN_URL -> { - launchCustomTab(uri.toString()) - return - } - } - if (uri.scheme == "sing-box" && uri.host == "import-remote-profile") { - val profile = try { - Libbox.parseRemoteProfileImportLink(uri.toString()) - } catch (e: Exception) { - errorDialogBuilder(e).show() - return - } - MaterialAlertDialogBuilder(this) - .setTitle(R.string.import_remote_profile) - .setMessage( - getString( - R.string.import_remote_profile_message, - profile.name, - profile.host - ) - ) - .setPositiveButton(R.string.ok) { _, _ -> - startActivity(Intent(this, NewProfileActivity::class.java).apply { - putExtra("importName", profile.name) - putExtra("importURL", profile.url) - }) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } else if (intent.action == Intent.ACTION_VIEW) { - try { - val data = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return - val content = Libbox.decodeProfileContent(data) - MaterialAlertDialogBuilder(this) - .setTitle(R.string.import_profile) - .setMessage( - getString( - R.string.import_profile_message, - content.name - ) - ) - .setPositiveButton(R.string.ok) { _, _ -> - lifecycleScope.launch { - withContext(Dispatchers.IO) { - runCatching { - importProfile(content) - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it).show() - } - } - } - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } catch (e: Exception) { - errorDialogBuilder(e).show() - } - } - } - - private suspend fun importProfile(content: ProfileContent) { - val typedProfile = TypedProfile() - val profile = Profile(name = content.name, typed = typedProfile) - profile.userOrder = ProfileManager.nextOrder() - when (content.type) { - Libbox.ProfileTypeLocal -> { - typedProfile.type = TypedProfile.Type.Local - } - - Libbox.ProfileTypeiCloud -> { - errorDialogBuilder(R.string.icloud_profile_unsupported).show() - return - } - - Libbox.ProfileTypeRemote -> { - typedProfile.type = TypedProfile.Type.Remote - typedProfile.remoteURL = content.remotePath - typedProfile.autoUpdate = content.autoUpdate - typedProfile.autoUpdateInterval = content.autoUpdateInterval - typedProfile.lastUpdated = Date(content.lastUpdated) - } - } - val configDirectory = File(filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "${profile.userOrder}.json") - configFile.writeText(content.config) - typedProfile.path = configFile.path - ProfileManager.create(profile) - } - - fun reconnect() { - connection.reconnect() - } - - private fun startIntegration() { - if (Vendor.checkUpdateAvailable()) { - lifecycleScope.launch(Dispatchers.IO) { - if (Settings.checkUpdateEnabled) { - Vendor.checkUpdate(this@MainActivity, false) - } - } - } - } - - @SuppressLint("NewApi") - fun startService() { - if (!ServiceNotification.checkPermission()) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - return - } - startService0() - } - - private fun startService0() { - lifecycleScope.launch(Dispatchers.IO) { - if (Settings.rebuildServiceMode()) { - reconnect() - } - if (Settings.serviceMode == ServiceMode.VPN) { - if (prepare()) { - return@launch - } - } - val intent = Intent(Application.application, Settings.serviceClass()) - withContext(Dispatchers.Main) { - ContextCompat.startForegroundService(Application.application, intent) - } - } - } - - private val notificationPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission() - ) { - if (Settings.dynamicNotification && !it) { - onServiceAlert(Alert.RequestNotificationPermission, null) - } else { - startService0() - } - } - - private val locationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) { - if (it && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - requestBackgroundLocationPermission() - } else { - startService() - } - } - } - - private val backgroundLocationPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it) { - startService() - } - } - - private val prepareLauncher = registerForActivityResult(PrepareService()) { - if (it) { - startService() - } else { - onServiceAlert(Alert.RequestVPNPermission, null) - } - } - - private class PrepareService : ActivityResultContract() { - override fun createIntent(context: Context, input: Intent): Intent { - return input - } - - override fun parseResult(resultCode: Int, intent: Intent?): Boolean { - return resultCode == RESULT_OK - } - } - - private suspend fun prepare() = withContext(Dispatchers.Main) { - try { - val intent = VpnService.prepare(this@MainActivity) - if (intent != null) { - prepareLauncher.launch(intent) - true - } else { - false - } - } catch (e: Exception) { - onServiceAlert(Alert.RequestVPNPermission, e.message) - false - } - } - - override fun onServiceStatusChanged(status: Status) { - serviceStatus.postValue(status) - } - - override fun onServiceAlert(type: Alert, message: String?) { - serviceStatus.value = Status.Stopped - - when (type) { - Alert.RequestLocationPermission -> { - return requestLocationPermission() - } - - else -> {} - } - - val builder = MaterialAlertDialogBuilder(this) - builder.setPositiveButton(R.string.ok, null) - when (type) { - Alert.RequestVPNPermission -> { - builder.setMessage(getString(R.string.service_error_missing_permission)) - } - - Alert.RequestNotificationPermission -> { - builder.setTitle(R.string.notification_permission_title) - builder.setMessage(R.string.notification_permission_required_description) - } - - Alert.EmptyConfiguration -> { - builder.setMessage(getString(R.string.service_error_empty_configuration)) - } - - Alert.StartCommandServer -> { - builder.setTitle(getString(R.string.service_error_title_start_command_server)) - builder.setMessage(message) - } - - Alert.CreateService -> { - builder.setTitle(getString(R.string.service_error_title_create_service)) - builder.setMessage(message) - } - - Alert.StartService -> { - builder.setTitle(getString(R.string.service_error_title_start_service)) - builder.setMessage(message) - - } - - else -> {} - } - builder.show() - } - - private fun requestLocationPermission() { - if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { - requestFineLocationPermission() - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - requestBackgroundLocationPermission() - } - } - - private fun requestFineLocationPermission() { - val message = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - Html.fromHtml( - getString(R.string.location_permission_description), - Html.FROM_HTML_MODE_LEGACY - ) - } else { - @Suppress("DEPRECATION") - Html.fromHtml(getString(R.string.location_permission_description)) - } - MaterialAlertDialogBuilder(this) - .setTitle(R.string.location_permission_title) - .setMessage(message) - .setPositiveButton(R.string.ok) { _, _ -> - requestFineLocationPermission0() - } - .setNegativeButton(R.string.no_thanks, null) - .setCancelable(false) - .show() - } - - private fun requestFineLocationPermission0() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) - } else { - openPermissionSettings() - } - } - - @RequiresApi(Build.VERSION_CODES.Q) - private fun requestBackgroundLocationPermission() { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.location_permission_title) - .setMessage( - Html.fromHtml( - getString(R.string.location_permission_background_description), - Html.FROM_HTML_MODE_LEGACY - ) - ) - .setPositiveButton(R.string.ok) { _, _ -> - backgroundLocationPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) - } - .setNegativeButton(R.string.no_thanks, null) - .setCancelable(false) - .show() - } - - private fun openPermissionSettings() { - if (MIUIUtils.isMIUI) { - try { - MIUIUtils.openPermissionSettings(this) - return - } catch (ignored: Exception) { - } - } - - try { - val intent = Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - intent.data = Uri.parse("package:$packageName") - startActivity(intent) - } catch (e: Exception) { - errorDialogBuilder(e).show() - } - } - - override fun onDestroy() { - connection.disconnect() - super.onDestroy() - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt deleted file mode 100644 index a91e0b6c35..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/ShortcutActivity.kt +++ /dev/null @@ -1,94 +0,0 @@ -package io.nekohasekai.sfa.ui - -import android.app.Activity -import android.app.KeyguardManager -import android.content.Intent -import android.content.pm.ShortcutManager -import android.os.Build -import android.os.Bundle -import androidx.core.content.getSystemService -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.bg.ServiceConnection -import io.nekohasekai.sfa.constant.Status - -class ShortcutActivity : Activity(), ServiceConnection.Callback { - - private val connection = ServiceConnection(this, this, false) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (intent.action == Intent.ACTION_CREATE_SHORTCUT) { - setResult( - RESULT_OK, ShortcutManagerCompat.createShortcutResultIntent( - this, - ShortcutInfoCompat.Builder(this, "toggle") - .setIntent( - Intent( - this, - ShortcutActivity::class.java - ).setAction(Intent.ACTION_MAIN) - ) - .setIcon( - IconCompat.createWithResource( - this, - R.mipmap.ic_launcher - ) - ) - .setShortLabel(getString(R.string.quick_toggle)) - .build() - ) - ) - finish() - } else { - val keyguardManager = getSystemService() - if (keyguardManager?.isKeyguardLocked == true) { - if (Build.VERSION.SDK_INT >= 26) { - keyguardManager.requestDismissKeyguard(this, object : KeyguardManager.KeyguardDismissCallback() { - override fun onDismissSucceeded() { - super.onDismissSucceeded() - connectAndToggle() - } - override fun onDismissCancelled() { - super.onDismissCancelled() - finish() - } - override fun onDismissError() { - super.onDismissError() - finish() - } - }) - } else { - finish() - } - } else { - connectAndToggle() - } - } - } - - private fun connectAndToggle() { - connection.connect() - if (Build.VERSION.SDK_INT >= 25) { - getSystemService()?.reportShortcutUsed("toggle") - } - } - - override fun onServiceStatusChanged(status: Status) { - when (status) { - Status.Started -> BoxService.stop() - Status.Stopped -> BoxService.start() - else -> {} - } - finish() - } - - override fun onDestroy() { - connection.disconnect() - super.onDestroy() - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt deleted file mode 100644 index b851bb5976..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/GroupsFragment.kt +++ /dev/null @@ -1,328 +0,0 @@ -package io.nekohasekai.sfa.ui.dashboard - -import android.annotation.SuppressLint -import android.os.Bundle -import android.text.TextWatcher -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.SimpleItemAnimator -import com.google.android.material.textfield.MaterialAutoCompleteTextView -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.OutboundGroup -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.databinding.FragmentDashboardGroupsBinding -import io.nekohasekai.sfa.databinding.ViewDashboardGroupBinding -import io.nekohasekai.sfa.databinding.ViewDashboardGroupItemBinding -import io.nekohasekai.sfa.ktx.colorForURLTestDelay -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - - -class GroupsFragment : Fragment(), CommandClient.Handler { - - private val activity: MainActivity? get() = super.getActivity() as MainActivity? - private var binding: FragmentDashboardGroupsBinding? = null - private var adapter: Adapter? = null - private val commandClient = - CommandClient(lifecycleScope, CommandClient.ConnectionType.Groups, this) - - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val binding = FragmentDashboardGroupsBinding.inflate(inflater, container, false) - this.binding = binding - onCreate() - return binding.root - } - - private fun onCreate() { - val activity = activity ?: return - val binding = binding ?: return - adapter = Adapter() - binding.container.adapter = adapter - binding.container.layoutManager = LinearLayoutManager(requireContext()) - activity.serviceStatus.observe(viewLifecycleOwner) { - if (it == Status.Started) { - commandClient.connect() - } - } - } - - override fun onDestroyView() { - super.onDestroyView() - binding = null - commandClient.disconnect() - } - - private var displayed = false - private fun updateDisplayed(newValue: Boolean) { - val binding = binding ?: return - if (displayed != newValue) { - displayed = newValue - binding.statusText.isVisible = !displayed - binding.container.isVisible = displayed - } - } - - override fun onConnected() { - lifecycleScope.launch(Dispatchers.Main) { - updateDisplayed(true) - } - } - - override fun onDisconnected() { - lifecycleScope.launch(Dispatchers.Main) { - updateDisplayed(false) - } - } - - @SuppressLint("NotifyDataSetChanged") - override fun updateGroups(newGroups: MutableList) { - val adapter = adapter ?: return - activity?.runOnUiThread { - updateDisplayed(newGroups.isNotEmpty()) - adapter.setGroups(newGroups.map(::Group)) - } - } - - private class Adapter : RecyclerView.Adapter() { - - private lateinit var groups: MutableList - - @SuppressLint("NotifyDataSetChanged") - fun setGroups(newGroups: List) { - if (!::groups.isInitialized || groups.size != newGroups.size) { - groups = newGroups.toMutableList() - notifyDataSetChanged() - } else { - newGroups.forEachIndexed { index, group -> - if (this.groups[index] != group) { - this.groups[index] = group - notifyItemChanged(index) - } - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GroupView { - return GroupView( - ViewDashboardGroupBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ), - ) - } - - override fun getItemCount(): Int { - if (!::groups.isInitialized) { - return 0 - } - return groups.size - } - - override fun onBindViewHolder(holder: GroupView, position: Int) { - holder.bind(groups[position]) - } - } - - private class GroupView(val binding: ViewDashboardGroupBinding) : - RecyclerView.ViewHolder(binding.root) { - - private lateinit var group: Group - private lateinit var items: List - private lateinit var adapter: ItemAdapter - private var textWatcher: TextWatcher? = null - - @OptIn(DelicateCoroutinesApi::class) - @SuppressLint("NotifyDataSetChanged") - fun bind(group: Group) { - this.group = group - binding.groupName.text = group.tag - binding.groupType.text = Libbox.proxyDisplayType(group.type) - binding.urlTestButton.setOnClickListener { - GlobalScope.launch { - runCatching { - Libbox.newStandaloneCommandClient().urlTest(group.tag) - }.onFailure { - withContext(Dispatchers.Main) { - binding.root.context.errorDialogBuilder(it).show() - } - } - } - } - items = group.items - if (!::adapter.isInitialized) { - adapter = ItemAdapter(this, group, items.toMutableList()) - binding.itemList.adapter = adapter - (binding.itemList.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = - false - binding.itemList.layoutManager = GridLayoutManager(binding.root.context, 2) - } else { - adapter.group = group - adapter.setItems(items) - } - updateExpand() - } - - @OptIn(DelicateCoroutinesApi::class) - private fun updateExpand(isExpand: Boolean? = null) { - val newExpandStatus = isExpand ?: group.isExpand - if (isExpand != null) { - GlobalScope.launch { - runCatching { - Libbox.newStandaloneCommandClient().setGroupExpand(group.tag, isExpand) - }.onFailure { - withContext(Dispatchers.Main) { - binding.root.context.errorDialogBuilder(it).show() - } - } - } - } - binding.itemList.isVisible = newExpandStatus - binding.groupSelected.isVisible = !newExpandStatus - val textView = (binding.groupSelected.editText as MaterialAutoCompleteTextView) - if (textWatcher != null) { - textView.removeTextChangedListener(textWatcher) - } - if (!newExpandStatus) { - binding.groupSelected.text = group.selected - binding.groupSelected.isEnabled = group.selectable - if (group.selectable) { - textView.setSimpleItems(group.items.toList().map { it.tag }.toTypedArray()) - textWatcher = textView.addTextChangedListener { - val selected = textView.text.toString() - if (selected != group.selected) { - updateSelected(group, selected) - } - GlobalScope.launch { - runCatching { - Libbox.newStandaloneCommandClient() - .selectOutbound(group.tag, selected) - }.onFailure { - withContext(Dispatchers.Main) { - binding.root.context.errorDialogBuilder(it).show() - } - } - } - } - } - } - if (newExpandStatus) { - binding.urlTestButton.isVisible = true - binding.expandButton.setImageResource(R.drawable.ic_expand_less_24) - } else { - binding.urlTestButton.isVisible = false - binding.expandButton.setImageResource(R.drawable.ic_expand_more_24) - } - binding.expandButton.setOnClickListener { - updateExpand(!binding.itemList.isVisible) - } - } - - fun updateSelected(group: Group, itemTag: String) { - val oldSelected = items.indexOfFirst { it.tag == group.selected } - group.selected = itemTag - if (oldSelected != -1) { - adapter.notifyItemChanged(oldSelected) - } - } - } - - private class ItemAdapter( - val groupView: GroupView, - var group: Group, - private var items: MutableList = mutableListOf() - ) : - RecyclerView.Adapter() { - - @SuppressLint("NotifyDataSetChanged") - fun setItems(newItems: List) { - if (items.size != newItems.size) { - items = newItems.toMutableList() - notifyDataSetChanged() - } else { - newItems.forEachIndexed { index, item -> - if (items[index] != item) { - items[index] = item - notifyItemChanged(index) - } - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemGroupView { - return ItemGroupView( - ViewDashboardGroupItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onBindViewHolder(holder: ItemGroupView, position: Int) { - holder.bind(groupView, group, items[position]) - } - } - - private class ItemGroupView(val binding: ViewDashboardGroupItemBinding) : - RecyclerView.ViewHolder(binding.root) { - - @OptIn(DelicateCoroutinesApi::class) - fun bind(groupView: GroupView, group: Group, item: GroupItem) { - if (group.selectable) { - binding.itemCard.setOnClickListener { - binding.selectedView.isVisible = true - groupView.updateSelected(group, item.tag) - GlobalScope.launch { - runCatching { - Libbox.newStandaloneCommandClient().selectOutbound(group.tag, item.tag) - }.onFailure { - withContext(Dispatchers.Main) { - binding.root.context.errorDialogBuilder("select outbound: ${it.localizedMessage}") - .show() - } - } - } - } - } - binding.selectedView.isInvisible = group.selected != item.tag - binding.itemName.text = item.tag - binding.itemType.text = Libbox.proxyDisplayType(item.type) - binding.itemStatus.isVisible = item.urlTestTime > 0 - if (item.urlTestTime > 0) { - binding.itemStatus.text = "${item.urlTestDelay}ms" - binding.itemStatus.setTextColor( - colorForURLTestDelay( - binding.root.context, - item.urlTestDelay - ) - ) - } - } - } -} - diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt deleted file mode 100644 index 75ac88cb35..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/dashboard/OverviewFragment.kt +++ /dev/null @@ -1,379 +0,0 @@ -package io.nekohasekai.sfa.ui.dashboard - -import android.annotation.SuppressLint -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.GridLayoutManager -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.divider.MaterialDividerItemDecoration -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.libbox.StatusMessage -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.databinding.FragmentDashboardOverviewBinding -import io.nekohasekai.sfa.databinding.ViewClashModeButtonBinding -import io.nekohasekai.sfa.databinding.ViewProfileItemBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.getAttrColor -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class OverviewFragment : Fragment() { - - private val activity: MainActivity? get() = super.getActivity() as MainActivity? - private var binding: FragmentDashboardOverviewBinding? = null - private val statusClient = - CommandClient(lifecycleScope, CommandClient.ConnectionType.Status, StatusClient()) - private val clashModeClient = - CommandClient(lifecycleScope, CommandClient.ConnectionType.ClashMode, ClashModeClient()) - - private var adapter: Adapter? = null - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val binding = FragmentDashboardOverviewBinding.inflate(inflater, container, false) - this.binding = binding - onCreate() - return binding.root - } - - private fun onCreate() { - val activity = activity ?: return - val binding = binding ?: return - binding.profileList.adapter = Adapter(lifecycleScope, binding).apply { - adapter = this - reload() - } - binding.profileList.layoutManager = LinearLayoutManager(requireContext()) - val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL) - divider.isLastItemDecorated = false - binding.profileList.addItemDecoration(divider) - activity.serviceStatus.observe(viewLifecycleOwner) { - binding.statusContainer.isVisible = it == Status.Starting || it == Status.Started - when (it) { - Status.Stopped -> { - binding.clashModeCard.isVisible = false - binding.systemProxyCard.isVisible = false - - } - - Status.Started -> { - statusClient.connect() - clashModeClient.connect() - reloadSystemProxyStatus() - - } - - else -> {} - } - } - - ProfileManager.registerCallback(this::updateProfiles) - } - - override fun onDestroyView() { - super.onDestroyView() - adapter = null - binding = null - statusClient.disconnect() - clashModeClient.disconnect() - ProfileManager.unregisterCallback(this::updateProfiles) - } - - private fun updateProfiles() { - adapter?.reload() - } - - private fun reloadSystemProxyStatus() { - val binding = binding ?: return - lifecycleScope.launch(Dispatchers.IO) { - val status = Libbox.newStandaloneCommandClient().systemProxyStatus - withContext(Dispatchers.Main) { - binding.systemProxyCard.isVisible = status.available - binding.systemProxySwitch.setOnCheckedChangeListener(null) - binding.systemProxySwitch.isChecked = status.enabled - var reloading = false - binding.systemProxySwitch.setOnCheckedChangeListener { buttonView, isChecked -> - synchronized(this@OverviewFragment) { - if (reloading) return@setOnCheckedChangeListener - reloading = true - binding.systemProxySwitch.isEnabled = false - lifecycleScope.launch(Dispatchers.IO) { - Settings.systemProxyEnabled = isChecked - runCatching { - Libbox.newStandaloneCommandClient().setSystemProxyEnabled(isChecked) - }.onFailure { - withContext(Dispatchers.Main) { - buttonView.context.errorDialogBuilder(it).show() - } - } - withContext(Dispatchers.Main) { - delay(1000L) - binding.systemProxySwitch.isEnabled = true - } - } - } - } - } - } - } - - inner class StatusClient : CommandClient.Handler { - - override fun onConnected() { - val binding = binding ?: return - lifecycleScope.launch(Dispatchers.Main) { - binding.memoryText.text = getString(R.string.loading) - binding.goroutinesText.text = getString(R.string.loading) - } - } - - override fun onDisconnected() { - val binding = binding ?: return - lifecycleScope.launch(Dispatchers.Main) { - binding.memoryText.text = getString(R.string.loading) - binding.goroutinesText.text = getString(R.string.loading) - } - } - - override fun updateStatus(status: StatusMessage) { - val binding = binding ?: return - lifecycleScope.launch(Dispatchers.Main) { - binding.memoryText.text = Libbox.formatBytes(status.memory) - binding.goroutinesText.text = status.goroutines.toString() - val trafficAvailable = status.trafficAvailable - binding.trafficContainer.isVisible = trafficAvailable - if (trafficAvailable) { - binding.inboundConnectionsText.text = status.connectionsIn.toString() - binding.outboundConnectionsText.text = status.connectionsOut.toString() - binding.uplinkText.text = Libbox.formatBytes(status.uplink) + "/s" - binding.downlinkText.text = Libbox.formatBytes(status.downlink) + "/s" - binding.uplinkTotalText.text = Libbox.formatBytes(status.uplinkTotal) - binding.downlinkTotalText.text = Libbox.formatBytes(status.downlinkTotal) - } - } - } - - } - - inner class ClashModeClient : CommandClient.Handler { - - override fun initializeClashMode(modeList: List, currentMode: String) { - val binding = binding ?: return - if (modeList.size > 1) { - lifecycleScope.launch(Dispatchers.Main) { - binding.clashModeCard.isVisible = true - binding.clashModeList.adapter = ClashModeAdapter(modeList, currentMode) - binding.clashModeList.layoutManager = - GridLayoutManager( - requireContext(), - if (modeList.size < 3) modeList.size else 3 - ) - } - } else { - lifecycleScope.launch(Dispatchers.Main) { - binding.clashModeCard.isVisible = false - } - } - } - - @SuppressLint("NotifyDataSetChanged") - override fun updateClashMode(newMode: String) { - val binding = binding ?: return - val adapter = binding.clashModeList.adapter as? ClashModeAdapter ?: return - adapter.selected = newMode - lifecycleScope.launch(Dispatchers.Main) { - adapter.notifyDataSetChanged() - } - } - - } - - private inner class ClashModeAdapter( - val items: List, - var selected: String - ) : - RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ClashModeItemView { - val view = ClashModeItemView( - ViewClashModeButtonBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - view.binding.clashModeButton.clipToOutline = true - return view - } - - override fun getItemCount(): Int { - return items.size - } - - override fun onBindViewHolder(holder: ClashModeItemView, position: Int) { - holder.bind(items[position], selected) - } - } - - private inner class ClashModeItemView(val binding: ViewClashModeButtonBinding) : - RecyclerView.ViewHolder(binding.root) { - - @OptIn(DelicateCoroutinesApi::class) - fun bind(item: String, selected: String) { - binding.clashModeButtonText.text = item - if (item != selected) { - binding.clashModeButtonText.setTextColor( - binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimaryContainer) - ) - binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle) - binding.clashModeButton.setOnClickListener { - runCatching { - Libbox.newStandaloneCommandClient().setClashMode(item) - clashModeClient.connect() - }.onFailure { - GlobalScope.launch(Dispatchers.Main) { - binding.root.context.errorDialogBuilder(it).show() - } - } - } - } else { - binding.clashModeButtonText.setTextColor( - binding.root.context.getAttrColor(com.google.android.material.R.attr.colorOnPrimary) - ) - binding.clashModeButton.setBackgroundResource(R.drawable.bg_rounded_rectangle_active) - binding.clashModeButton.isClickable = false - } - - } - } - - - class Adapter( - internal val scope: CoroutineScope, - internal val parent: FragmentDashboardOverviewBinding - ) : - RecyclerView.Adapter() { - - internal var items: MutableList = mutableListOf() - internal var selectedProfileID = -1L - internal var lastSelectedIndex: Int? = null - internal fun reload() { - scope.launch(Dispatchers.IO) { - items = ProfileManager.list().toMutableList() - if (items.isNotEmpty()) { - selectedProfileID = Settings.selectedProfile - for ((index, profile) in items.withIndex()) { - if (profile.id == selectedProfileID) { - lastSelectedIndex = index - break - } - } - if (lastSelectedIndex == null) { - lastSelectedIndex = 0 - selectedProfileID = items[0].id - Settings.selectedProfile = selectedProfileID - } - } - withContext(Dispatchers.Main) { - parent.statusText.isVisible = items.isEmpty() - parent.container.isVisible = items.isNotEmpty() - notifyDataSetChanged() - } - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { - return Holder( - this, - ViewProfileItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindViewHolder(holder: Holder, position: Int) { - holder.bind(items[position]) - } - - override fun getItemCount(): Int { - return items.size - } - - } - - class Holder( - private val adapter: Adapter, - private val binding: ViewProfileItemBinding - ) : - RecyclerView.ViewHolder(binding.root) { - - internal fun bind(profile: Profile) { - binding.profileName.text = profile.name - binding.profileSelected.setOnCheckedChangeListener(null) - binding.profileSelected.isChecked = profile.id == adapter.selectedProfileID - binding.profileSelected.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) { - adapter.parent.profileList.isClickable = false - adapter.selectedProfileID = profile.id - adapter.lastSelectedIndex?.let { index -> - adapter.notifyItemChanged(index) - } - adapter.lastSelectedIndex = adapterPosition - adapter.scope.launch(Dispatchers.IO) { - switchProfile(profile) - withContext(Dispatchers.Main) { - adapter.parent.profileList.isEnabled = true - } - } - } - } - binding.root.setOnClickListener { - binding.profileSelected.toggle() - } - } - - private suspend fun switchProfile(profile: Profile) { - Settings.selectedProfile = profile.id - val mainActivity = (binding.root.context as? MainActivity) ?: return - val started = mainActivity.serviceStatus.value == Status.Started - if (!started) { - return - } - val restart = Settings.rebuildServiceMode() - if (restart) { - mainActivity.reconnect() - BoxService.stop() - delay(1000L) - mainActivity.startService() - return - } - runCatching { - Libbox.newStandaloneCommandClient().serviceReload() - }.onFailure { - withContext(Dispatchers.Main) { - mainActivity.errorDialogBuilder(it).show() - } - } - } - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt deleted file mode 100644 index cf837910b7..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/DebugActivity.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.nekohasekai.sfa.ui.debug - -import android.content.Intent -import android.os.Bundle -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.databinding.ActivityDebugBinding -import io.nekohasekai.sfa.ui.shared.AbstractActivity - -class DebugActivity : AbstractActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_debug) - binding.scanVPNButton.setOnClickListener { - startActivity(Intent(this, VPNScanActivity::class.java)) - } - } -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt deleted file mode 100644 index 8439d197eb..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/debug/VPNScanActivity.kt +++ /dev/null @@ -1,262 +0,0 @@ -package io.nekohasekai.sfa.ui.debug - -import android.Manifest -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.databinding.ActivityVpnScanBinding -import io.nekohasekai.sfa.databinding.ViewVpnAppItemBinding -import io.nekohasekai.sfa.ktx.dp2px -import io.nekohasekai.sfa.ktx.toStringIterator -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.util.zip.ZipFile -import kotlin.math.roundToInt - -class VPNScanActivity : AbstractActivity() { - - private var adapter: Adapter? = null - private val appInfoList = mutableListOf() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_scan_vpn) - - ViewCompat.setOnApplyWindowInsetsListener(binding.scanVPNResult) { view, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.updatePadding(bottom = insets.bottom + dp2px(16)) - WindowInsetsCompat.CONSUMED - } - - binding.scanVPNResult.adapter = Adapter().also { - adapter = it - } - binding.scanVPNResult.layoutManager = LinearLayoutManager(this) - lifecycleScope.launch(Dispatchers.IO) { - scanVPN() - } - } - - class VPNType( - val appType: String?, - val coreType: VPNCoreType?, - ) - - class VPNCoreType( - val coreType: String, - val corePath: String, - val goVersion: String - ) - - class AppInfo( - val packageInfo: PackageInfo, - val vpnType: VPNType, - ) - - inner class Adapter : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { - return Holder(ViewVpnAppItemBinding.inflate(layoutInflater, parent, false)) - } - - override fun getItemCount(): Int { - return appInfoList.size - } - - override fun onBindViewHolder(holder: Holder, position: Int) { - holder.bind(appInfoList[position]) - } - } - - class Holder( - private val binding: ViewVpnAppItemBinding - ) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(element: AppInfo) { - binding.appIcon.setImageDrawable(element.packageInfo.applicationInfo!!.loadIcon(binding.root.context.packageManager)) - binding.appName.text = - element.packageInfo.applicationInfo!!.loadLabel(binding.root.context.packageManager) - binding.packageName.text = element.packageInfo.packageName - val appType = element.vpnType.appType - if (appType != null) { - binding.appTypeText.text = element.vpnType.appType - } else { - binding.appTypeText.setText(R.string.vpn_app_type_other) - } - val coreType = element.vpnType.coreType?.coreType - if (coreType != null) { - binding.coreTypeText.text = element.vpnType.coreType.coreType - } else { - binding.coreTypeText.setText(R.string.vpn_core_type_unknown) - } - val corePath = element.vpnType.coreType?.corePath.takeIf { !it.isNullOrBlank() } - if (corePath != null) { - binding.corePathLayout.isVisible = true - binding.corePathText.text = corePath - } else { - binding.corePathLayout.isVisible = false - } - - val goVersion = element.vpnType.coreType?.goVersion.takeIf { !it.isNullOrBlank() } - if (goVersion != null) { - binding.goVersionLayout.isVisible = true - binding.goVersionText.text = goVersion - } else { - binding.goVersionLayout.isVisible = false - } - } - } - - private suspend fun scanVPN() { - val adapter = adapter ?: return - val flag = - PackageManager.GET_SERVICES or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES - } else { - @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES - } - val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(flag.toLong())) - } else { - @Suppress("DEPRECATION") - packageManager.getInstalledPackages(flag) - } - val vpnAppList = - installedPackages.filter { - it.services?.any { it.permission == Manifest.permission.BIND_VPN_SERVICE && it.applicationInfo != null } - ?: false - } - for ((index, packageInfo) in vpnAppList.withIndex()) { - val appType = runCatching { getVPNAppType(packageInfo) }.getOrNull() - val coreType = runCatching { getVPNCoreType(packageInfo) }.getOrNull() - appInfoList.add(AppInfo(packageInfo, VPNType(appType, coreType))) - withContext(Dispatchers.Main) { - adapter.notifyItemInserted(index) - binding.scanVPNResult.scrollToPosition(index) - binding.scanVPNProgress.setProgressCompat( - (((index + 1).toFloat() / vpnAppList.size.toFloat()) * 100).roundToInt(), - true - ) - } - System.gc() - } - withContext(Dispatchers.Main) { - binding.scanVPNProgress.isVisible = false - } - } - - companion object { - - private val v2rayNGClasses = listOf( - "com.v2ray.ang", - ".dto.V2rayConfig", - ".service.V2RayVpnService", - ) - - private val clashForAndroidClasses = listOf( - "com.github.kr328.clash", - ".core.Clash", - ".service.TunService", - ) - - private val sfaClasses = listOf( - "io.nekohasekai.sfa" - ) - - private val legacySagerNetClasses = listOf( - "io.nekohasekai.sagernet", - ".fmt.ConfigBuilder" - ) - - private val shadowsocksAndroidClasses = listOf( - "com.github.shadowsocks", - ".bg.VpnService", - "GuardedProcessPool" - ) - } - - private fun getVPNAppType(packageInfo: PackageInfo): String? { - ZipFile(File(packageInfo.applicationInfo!!.publicSourceDir)).use { packageFile -> - for (packageEntry in packageFile.entries()) { - if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith( - ".dex" - )) - ) { - continue - } - if (packageEntry.size > 15000000) { - continue - } - val input = packageFile.getInputStream(packageEntry).buffered() - val dexFile = try { - DexBackedDexFile.fromInputStream(null, input) - } catch (e: Exception) { - Log.e("VPNScanActivity", "Failed to read dex file", e) - continue - } - for (clazz in dexFile.classes) { - val clazzName = clazz.type.substring(1, clazz.type.length - 1) - .replace("/", ".") - .replace("$", ".") - for (v2rayNGClass in v2rayNGClasses) { - if (clazzName.contains(v2rayNGClass)) { - return "V2RayNG" - } - } - for (clashForAndroidClass in clashForAndroidClasses) { - if (clazzName.contains(clashForAndroidClass)) { - return "ClashForAndroid" - } - } - for (sfaClass in sfaClasses) { - if (clazzName.contains(sfaClass)) { - return "sing-box" - } - } - for (legacySagerNetClass in legacySagerNetClasses) { - if (clazzName.contains(legacySagerNetClass)) { - return "LegacySagerNet" - } - } - for (shadowsocksAndroidClass in shadowsocksAndroidClasses) { - if (clazzName.contains(shadowsocksAndroidClass)) { - return "shadowsocks-android" - } - } - } - } - return null - } - } - - private fun getVPNCoreType(packageInfo: PackageInfo): VPNCoreType? { - val packageFiles = mutableListOf(packageInfo.applicationInfo!!.publicSourceDir) - packageInfo.applicationInfo!!.splitPublicSourceDirs?.also { - packageFiles.addAll(it) - } - val vpnType = try { - Libbox.readAndroidVPNType(packageFiles.toStringIterator()) - } catch (ignored: Exception) { - return null - } - return VPNCoreType(vpnType.coreType, vpnType.corePath, vpnType.goVersion) - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt deleted file mode 100644 index 5d40991052..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/ConfigurationFragment.kt +++ /dev/null @@ -1,281 +0,0 @@ -package io.nekohasekai.sfa.ui.main - -import android.annotation.SuppressLint -import android.content.Intent -import android.net.Uri -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.widget.PopupMenu -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.ItemTouchHelper -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.databinding.FragmentConfigurationBinding -import io.nekohasekai.sfa.databinding.SheetAddProfileBinding -import io.nekohasekai.sfa.databinding.ViewConfigutationItemBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.shareProfile -import io.nekohasekai.sfa.ktx.shareProfileURL -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.ui.profile.EditProfileActivity -import io.nekohasekai.sfa.ui.profile.NewProfileActivity -import io.nekohasekai.sfa.ui.profile.QRScanActivity -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.text.DateFormat -import java.util.Collections - -class ConfigurationFragment : Fragment() { - - private var adapter: Adapter? = null - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val binding = FragmentConfigurationBinding.inflate(inflater, container, false) - val adapter = Adapter(binding) - this.adapter = adapter - binding.profileList.also { - it.layoutManager = LinearLayoutManager(requireContext()) - it.adapter = adapter - ItemTouchHelper(object : - ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) { - - override fun onMove( - recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder, - target: RecyclerView.ViewHolder - ): Boolean { - return adapter.move(viewHolder.adapterPosition, target.adapterPosition) - } - - override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { - } - - override fun onSelectedChanged( - viewHolder: RecyclerView.ViewHolder?, - actionState: Int - ) { - super.onSelectedChanged(viewHolder, actionState) - if (actionState == ItemTouchHelper.ACTION_STATE_IDLE) { - adapter.updateUserOrder() - } - } - }).attachToRecyclerView(it) - } - adapter.reload() - binding.fab.setOnClickListener { - AddProfileDialog().show(childFragmentManager, "add_profile") - } - ProfileManager.registerCallback(this::updateProfiles) - return binding.root - } - - class AddProfileDialog : BottomSheetDialogFragment(R.layout.sheet_add_profile) { - - private val importFromFile = - registerForActivityResult(ActivityResultContracts.GetContent(), ::onImportResult) - - private val scanQrCode = - registerForActivityResult(QRScanActivity.Contract(), ::onScanResult) - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val binding = SheetAddProfileBinding.bind(view) - binding.importFromFile.setOnClickListener { - importFromFile.launch("*/*") - } - binding.scanQrCode.setOnClickListener { - scanQrCode.launch(null) - } - binding.createManually.setOnClickListener { - dismiss() - startActivity(Intent(requireContext(), NewProfileActivity::class.java)) - } - } - - private fun onImportResult(result: Uri?) { - dismiss() - (activity as? MainActivity ?: return).onNewIntent(Intent(Intent.ACTION_VIEW, result)) - } - - private fun onScanResult(result: Intent?) { - dismiss() - (activity as? MainActivity ?: return).onNewIntent(result ?: return) - } - } - - override fun onResume() { - super.onResume() - adapter?.reload() - } - - override fun onDestroyView() { - super.onDestroyView() - ProfileManager.unregisterCallback(this::updateProfiles) - adapter = null - } - - private fun updateProfiles() { - adapter?.reload() - } - - inner class Adapter( - private val parent: FragmentConfigurationBinding - ) : - RecyclerView.Adapter() { - - internal var items: MutableList = mutableListOf() - internal val scope = lifecycleScope - internal val fragmentActivity = requireActivity() - - @SuppressLint("NotifyDataSetChanged") - internal fun reload() { - lifecycleScope.launch(Dispatchers.IO) { - val newItems = ProfileManager.list().toMutableList() - withContext(Dispatchers.Main) { - items = newItems - notifyDataSetChanged() - if (items.isEmpty()) { - parent.statusText.isVisible = true - parent.profileList.isVisible = false - } else if (parent.statusText.isVisible) { - parent.statusText.isVisible = false - parent.profileList.isVisible = true - } - } - } - } - - internal fun move(from: Int, to: Int): Boolean { - if (from < to) { - for (i in from until to) { - Collections.swap(items, i, i + 1) - } - } else { - for (i in from downTo to + 1) { - Collections.swap(items, i, i - 1) - } - } - notifyItemMoved(from, to) - return true - } - - @OptIn(DelicateCoroutinesApi::class) - internal fun updateUserOrder() { - items.forEachIndexed { index, profile -> - profile.userOrder = index.toLong() - } - GlobalScope.launch(Dispatchers.IO) { - ProfileManager.update(items) - } - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { - return Holder( - this, - ViewConfigutationItemBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - ) - } - - override fun onBindViewHolder(holder: Holder, position: Int) { - holder.bind(items[position]) - } - - override fun getItemCount(): Int { - return items.size - } - - } - - class Holder(private val adapter: Adapter, private val binding: ViewConfigutationItemBinding) : - RecyclerView.ViewHolder(binding.root) { - - internal fun bind(profile: Profile) { - binding.profileName.text = profile.name - if (profile.typed.type == TypedProfile.Type.Remote) { - binding.profileLastUpdated.isVisible = true - binding.profileLastUpdated.text = binding.root.context.getString( - R.string.profile_item_last_updated, - DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated) - ) - } else { - binding.profileLastUpdated.isVisible = false - } - binding.root.setOnClickListener { - val intent = Intent(binding.root.context, EditProfileActivity::class.java) - intent.putExtra("profile_id", profile.id) - it.context.startActivity(intent) - } - binding.moreButton.setOnClickListener { button -> - val popup = PopupMenu(button.context, button) - popup.setForceShowIcon(true) - popup.menuInflater.inflate(R.menu.profile_menu, popup.menu) - if (profile.typed.type != TypedProfile.Type.Remote) { - popup.menu.removeItem(R.id.action_share_url) - } - popup.setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_share -> { - adapter.scope.launch(Dispatchers.IO) { - try { - button.context.shareProfile(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - button.context.errorDialogBuilder(e).show() - } - } - } - true - } - - R.id.action_share_url -> { - adapter.scope.launch(Dispatchers.IO) { - try { - adapter.fragmentActivity.shareProfileURL(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - button.context.errorDialogBuilder(e).show() - } - } - } - true - } - - R.id.action_delete -> { - adapter.items.remove(profile) - adapter.notifyItemRemoved(adapterPosition) - adapter.scope.launch(Dispatchers.IO) { - runCatching { - ProfileManager.delete(profile) - } - } - true - } - - else -> false - } - } - popup.show() - } - } - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt deleted file mode 100644 index 9a8367914e..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/DashboardFragment.kt +++ /dev/null @@ -1,181 +0,0 @@ -package io.nekohasekai.sfa.ui.main - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.viewpager2.adapter.FragmentStateAdapter -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.tabs.TabLayoutMediator -import io.nekohasekai.libbox.DeprecatedNoteIterator -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.databinding.FragmentDashboardBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.launchCustomTab -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.ui.dashboard.GroupsFragment -import io.nekohasekai.sfa.ui.dashboard.OverviewFragment -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class DashboardFragment : Fragment(R.layout.fragment_dashboard) { - - private val activity: MainActivity? get() = super.getActivity() as MainActivity? - private var binding: FragmentDashboardBinding? = null - private var mediator: TabLayoutMediator? = null - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val binding = FragmentDashboardBinding.inflate(inflater, container, false) - this.binding = binding - onCreate() - return binding.root - } - - private val adapter by lazy { Adapter(this) } - private fun onCreate() { - val activity = activity ?: return - val binding = binding ?: return - binding.dashboardPager.adapter = adapter - binding.dashboardPager.offscreenPageLimit = Page.values().size - activity.serviceStatus.observe(viewLifecycleOwner) { - when (it) { - Status.Stopped -> { - disablePager() - binding.fab.setImageResource(R.drawable.ic_play_arrow_24) - binding.fab.show() - binding.fab.isEnabled = true - } - - Status.Starting -> { - binding.fab.hide() - } - - Status.Started -> { - checkDeprecatedNotes() - enablePager() - binding.fab.setImageResource(R.drawable.ic_stop_24) - binding.fab.show() - binding.fab.isEnabled = true - } - - Status.Stopping -> { - disablePager() - binding.fab.hide() - } - - else -> {} - } - } - binding.fab.setOnClickListener { - when (activity.serviceStatus.value) { - Status.Stopped -> { - it.isEnabled = false - activity.startService() - } - - Status.Started -> { - BoxService.stop() - } - - else -> {} - } - } - } - - override fun onStart() { - super.onStart() - val activityBinding = activity?.binding ?: return - val binding = binding ?: return - if (mediator != null) return - mediator = TabLayoutMediator( - activityBinding.dashboardTabLayout, - binding.dashboardPager - ) { tab, position -> - tab.setText(Page.values()[position].titleRes) - }.apply { attach() } - } - - override fun onDestroyView() { - super.onDestroyView() - mediator?.detach() - mediator = null - binding?.dashboardPager?.adapter = null - binding = null - } - - private fun checkDeprecatedNotes() { - GlobalScope.launch(Dispatchers.IO) { - runCatching { - val notes = Libbox.newStandaloneCommandClient().deprecatedNotes - if (notes.hasNext()) { - withContext(Dispatchers.Main) { - loopShowDeprecatedNotes(notes) - } - } - }.onFailure { - withContext(Dispatchers.Main) { - activity?.errorDialogBuilder(it)?.show() - } - } - } - } - - private fun loopShowDeprecatedNotes(notes: DeprecatedNoteIterator) { - if (notes.hasNext()) { - val note = notes.next() - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setTitle(getString(R.string.service_error_title_deprecated_warning)) - builder.setMessage(note.message()) - builder.setPositiveButton(R.string.ok) { _, _ -> - loopShowDeprecatedNotes(notes) - } - if (!note.migrationLink.isNullOrBlank()) { - builder.setNeutralButton(R.string.service_error_deprecated_warning_documentation) { _, _ -> - requireContext().launchCustomTab(note.migrationLink) - loopShowDeprecatedNotes(notes) - } - } - builder.show() - } - } - - private fun enablePager() { - val activity = activity ?: return - val binding = binding ?: return - activity.binding.dashboardTabLayout.isVisible = true - binding.dashboardPager.isUserInputEnabled = true - } - - private fun disablePager() { - val activity = activity ?: return - val binding = binding ?: return - activity.binding.dashboardTabLayout.isVisible = false - binding.dashboardPager.isUserInputEnabled = false - binding.dashboardPager.setCurrentItem(0, false) - } - - enum class Page(@StringRes val titleRes: Int, val fragmentClass: Class) { - Overview(R.string.title_overview, OverviewFragment::class.java), - Groups(R.string.title_groups, GroupsFragment::class.java); - } - - class Adapter(parent: Fragment) : FragmentStateAdapter(parent) { - override fun getItemCount(): Int { - return Page.entries.size - } - - override fun createFragment(position: Int): Fragment { - return Page.entries[position].fragmentClass.getConstructor().newInstance() - } - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt deleted file mode 100644 index 2ae6da0f82..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/LogFragment.kt +++ /dev/null @@ -1,181 +0,0 @@ -package io.nekohasekai.sfa.ui.main - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.BoxService -import io.nekohasekai.sfa.constant.Status -import io.nekohasekai.sfa.databinding.FragmentLogBinding -import io.nekohasekai.sfa.databinding.ViewLogTextItemBinding -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.utils.ColorUtils -import io.nekohasekai.sfa.utils.CommandClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.util.LinkedList - -class LogFragment : Fragment(), CommandClient.Handler { - private val activity: MainActivity? get() = super.getActivity() as MainActivity? - private var binding: FragmentLogBinding? = null - private var adapter: Adapter? = null - private val commandClient = - CommandClient(lifecycleScope, CommandClient.ConnectionType.Log, this) - private val logList = LinkedList() - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val binding = FragmentLogBinding.inflate(inflater, container, false) - this.binding = binding - onCreate() - return binding.root - } - - private fun onCreate() { - val activity = activity ?: return - val binding = binding ?: return - binding.logView.layoutManager = LinearLayoutManager(requireContext()) - binding.logView.adapter = Adapter(logList).also { adapter = it } - updateViews() - activity.serviceStatus.observe(viewLifecycleOwner) { - when (it) { - Status.Stopped -> { - binding.fab.setImageResource(R.drawable.ic_play_arrow_24) - binding.fab.show() - binding.statusText.setText(R.string.status_default) - } - - Status.Starting -> { - binding.fab.hide() - binding.statusText.setText(R.string.status_starting) - } - - Status.Started -> { - commandClient.connect() - binding.fab.setImageResource(R.drawable.ic_stop_24) - binding.fab.show() - binding.fab.isEnabled = true - binding.statusText.setText(R.string.status_started) - } - - Status.Stopping -> { - binding.fab.hide() - binding.statusText.setText(R.string.status_stopping) - } - - else -> {} - } - } - binding.fab.setOnClickListener { - when (activity.serviceStatus.value) { - Status.Stopped -> { - it.isEnabled = false - activity.startService() - } - - Status.Started -> { - BoxService.stop() - } - - else -> {} - } - } - } - - private fun updateViews(removeLen: Int = 0, insertLen: Int = 0) { - val activity = activity ?: return - val logAdapter = adapter ?: return - val binding = binding ?: return - if (logList.isEmpty()) { - binding.logView.isVisible = false - binding.statusText.isVisible = true - } else if (!binding.logView.isVisible) { - binding.logView.isVisible = true - binding.statusText.isVisible = false - } - if (insertLen == 0) { - logAdapter.notifyDataSetChanged() - if (logList.size > 0) { - binding.logView.scrollToPosition(logList.size - 1) - } - } else { - if (logList.size == 300) { - logAdapter.notifyItemRangeRemoved(0, removeLen) - } - logAdapter.notifyItemRangeInserted(logList.size - insertLen, insertLen) - binding.logView.scrollToPosition(logList.size - 1) - } - } - - override fun onDestroyView() { - super.onDestroyView() - commandClient.disconnect() - binding = null - adapter = null - } - - override fun onConnected() { - lifecycleScope.launch(Dispatchers.Main) { - logList.clear() - updateViews() - } - } - - override fun clearLogs() { - lifecycleScope.launch(Dispatchers.Main) { - logList.clear() - updateViews() - } - } - - override fun appendLogs(messageList: List) { - lifecycleScope.launch(Dispatchers.Main) { - val messageLen = messageList.size - val removeLen = logList.size + messageLen - 300 - logList.addAll(messageList) - if (removeLen > 0) { - repeat(removeLen) { - logList.removeFirst() - } - } - updateViews(removeLen, messageLen) - } - } - - - class Adapter(private val logList: LinkedList) : - RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): LogViewHolder { - return LogViewHolder( - ViewLogTextItemBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - ) - } - - override fun onBindViewHolder(holder: LogViewHolder, position: Int) { - holder.bind(logList.getOrElse(position) { "" }) - } - - override fun getItemCount(): Int { - return logList.size - } - - } - - class LogViewHolder(private val binding: ViewLogTextItemBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(message: String) { - binding.text.text = ColorUtils.ansiEscapeToSpannable(binding.root.context, message) - } - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt deleted file mode 100644 index 93f7d38045..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/main/SettingsFragment.kt +++ /dev/null @@ -1,152 +0,0 @@ -package io.nekohasekai.sfa.ui.main - -import android.annotation.SuppressLint -import android.content.Intent -import android.net.Uri -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.core.view.isGone -import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.EnabledType -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.databinding.FragmentSettingsBinding -import io.nekohasekai.sfa.ktx.addTextChangedListener -import io.nekohasekai.sfa.ktx.launchCustomTab -import io.nekohasekai.sfa.ktx.setSimpleItems -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.ui.debug.DebugActivity -import io.nekohasekai.sfa.ui.profileoverride.ProfileOverrideActivity -import io.nekohasekai.sfa.vendor.Vendor -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class SettingsFragment : Fragment() { - - private lateinit var binding: FragmentSettingsBinding - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - binding = FragmentSettingsBinding.inflate(inflater, container, false) - onCreate() - return binding.root - } - - @RequiresApi(Build.VERSION_CODES.M) - private val requestIgnoreBatteryOptimizations = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName)) { - binding.backgroundPermissionCard.isGone = true - } - } - - @SuppressLint("BatteryLife") - private fun onCreate() { - val activity = activity as MainActivity? ?: return - val binding = binding ?: return - binding.versionText.text = Libbox.version() - binding.clearButton.setOnClickListener { - lifecycleScope.launch(Dispatchers.IO) { - activity.getExternalFilesDir(null)?.deleteRecursively() - reloadSettings() - } - } - if (!Vendor.checkUpdateAvailable()) { - binding.checkUpdateEnabled.isVisible = false - binding.checkUpdateButton.isVisible = false - } - binding.checkUpdateEnabled.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - val newValue = EnabledType.valueOf(requireContext(), it).boolValue - Settings.checkUpdateEnabled = newValue - } - } - binding.checkUpdateButton.setOnClickListener { - Vendor.checkUpdate(activity, true) - } - binding.openPrivacyPolicyButton.setOnClickListener { - activity.launchCustomTab("https://sing-box.sagernet.org/clients/privacy/") - } - binding.disableMemoryLimit.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - val newValue = EnabledType.valueOf(requireContext(), it).boolValue - Settings.disableMemoryLimit = !newValue - } - } - binding.dynamicNotificationEnabled.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - val newValue = EnabledType.valueOf(requireContext(), it).boolValue - Settings.dynamicNotification = newValue - } - } - - binding.dontKillMyAppButton.setOnClickListener { - it.context.launchCustomTab("https://dontkillmyapp.com/") - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - binding.requestIgnoreBatteryOptimizationsButton.setOnClickListener { - requestIgnoreBatteryOptimizations.launch( - Intent( - android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, - Uri.parse("package:${Application.application.packageName}") - ) - ) - } - } - binding.configureOverridesButton.setOnClickListener { - startActivity(Intent(requireContext(), ProfileOverrideActivity::class.java)) - } - binding.openDebugButton.setOnClickListener { - startActivity(Intent(requireContext(), DebugActivity::class.java)) - } - binding.startSponserButton.setOnClickListener { - activity.launchCustomTab("https://sekai.icu/sponsors/") - } - lifecycleScope.launch(Dispatchers.IO) { - reloadSettings() - } - } - - private suspend fun reloadSettings() { - val activity = activity ?: return - val binding = binding ?: return - val dataSize = Libbox.formatBytes( - (activity.getExternalFilesDir(null) ?: activity.filesDir) - .walkTopDown().filter { it.isFile }.map { it.length() }.sum() - ) - val checkUpdateEnabled = Settings.checkUpdateEnabled - val removeBackgroundPermissionPage = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - Application.powerManager.isIgnoringBatteryOptimizations(Application.application.packageName) - } else { - true - } - val dynamicNotification = Settings.dynamicNotification - withContext(Dispatchers.Main) { - binding.dataSizeText.text = dataSize - binding.checkUpdateEnabled.text = - EnabledType.from(checkUpdateEnabled).getString(requireContext()) - binding.checkUpdateEnabled.setSimpleItems(R.array.enabled) - binding.disableMemoryLimit.text = - EnabledType.from(!Settings.disableMemoryLimit).getString(requireContext()) - binding.disableMemoryLimit.setSimpleItems(R.array.enabled) - binding.backgroundPermissionCard.isGone = removeBackgroundPermissionPage - binding.dynamicNotificationEnabled.text = - EnabledType.from(dynamicNotification).getString(requireContext()) - binding.dynamicNotificationEnabled.setSimpleItems(R.array.enabled) - } - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt deleted file mode 100644 index da26bbe617..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileActivity.kt +++ /dev/null @@ -1,202 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.content.Intent -import android.os.Bundle -import android.view.View -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.bg.UpdateProfileWork -import io.nekohasekai.sfa.constant.EnabledType -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.databinding.ActivityEditProfileBinding -import io.nekohasekai.sfa.ktx.addTextChangedListener -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.setSimpleItems -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.utils.HTTPClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.text.DateFormat -import java.util.Date - -class EditProfileActivity : AbstractActivity() { - - private lateinit var profile: Profile - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_edit_profile) - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - loadProfile() - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it) - .setPositiveButton(R.string.ok) { _, _ -> finish() } - .show() - } - } - } - } - - private suspend fun loadProfile() { - delay(200L) - val profileId = intent.getLongExtra("profile_id", -1L) - if (profileId == -1L) error("invalid arguments") - profile = ProfileManager.get(profileId) ?: error("invalid arguments") - withContext(Dispatchers.Main) { - binding.name.text = profile.name - binding.name.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - try { - profile.name = it - ProfileManager.update(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - errorDialogBuilder(e).show() - } - } - } - } - binding.type.text = profile.typed.type.getString(this@EditProfileActivity) - binding.editButton.setOnClickListener { - startActivity( - Intent( - this@EditProfileActivity, - EditProfileContentActivity::class.java - ).apply { - putExtra("profile_id", profile.id) - }) - } - when (profile.typed.type) { - TypedProfile.Type.Local -> { - binding.editButton.isVisible = true - binding.remoteFields.isVisible = false - } - - TypedProfile.Type.Remote -> { - binding.editButton.isVisible = false - binding.remoteFields.isVisible = true - binding.remoteURL.text = profile.typed.remoteURL - binding.lastUpdated.text = - DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated) - binding.autoUpdate.text = EnabledType.from(profile.typed.autoUpdate) - .getString(this@EditProfileActivity) - binding.autoUpdate.setSimpleItems(R.array.enabled) - binding.autoUpdateInterval.isVisible = profile.typed.autoUpdate - binding.autoUpdateInterval.text = profile.typed.autoUpdateInterval.toString() - } - } - binding.remoteURL.addTextChangedListener(this@EditProfileActivity::updateRemoteURL) - binding.autoUpdate.addTextChangedListener(this@EditProfileActivity::updateAutoUpdate) - binding.autoUpdateInterval.addTextChangedListener(this@EditProfileActivity::updateAutoUpdateInterval) - binding.updateButton.setOnClickListener(this@EditProfileActivity::updateProfile) - binding.profileLayout.isVisible = true - binding.progressView.isVisible = false - } - } - - - private fun updateRemoteURL(newValue: String) { - profile.typed.remoteURL = newValue - updateProfile() - } - - private fun updateAutoUpdate(newValue: String) { - val boolValue = EnabledType.valueOf(this, newValue).boolValue - if (profile.typed.autoUpdate == boolValue) { - return - } - binding.autoUpdateInterval.isVisible = boolValue - profile.typed.autoUpdate = boolValue - if (boolValue) { - lifecycleScope.launch(Dispatchers.IO) { - UpdateProfileWork.reconfigureUpdater() - } - } - updateProfile() - } - - private fun updateAutoUpdateInterval(newValue: String) { - if (newValue.isBlank()) { - binding.autoUpdateInterval.error = getString(R.string.profile_input_required) - return - } - val intValue = try { - newValue.toInt() - } catch (e: Exception) { - binding.autoUpdateInterval.error = e.localizedMessage - return - } - if (intValue < 15) { - binding.autoUpdateInterval.error = - getString(R.string.profile_auto_update_interval_minimum_hint) - return - } - binding.autoUpdateInterval.error = null - profile.typed.autoUpdateInterval = intValue - updateProfile() - } - - private fun updateProfile() { - binding.progressView.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - delay(200L) - try { - ProfileManager.update(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - errorDialogBuilder(e).show() - } - } - withContext(Dispatchers.Main) { - binding.progressView.isVisible = false - } - } - } - - @Suppress("UNUSED_PARAMETER") - private fun updateProfile(view: View) { - binding.progressView.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - var selectedProfileUpdated = false - try { - val content = HTTPClient().use { it.getString(profile.typed.remoteURL) } - Libbox.checkConfig(content) - val file = File(profile.typed.path) - if (file.readText() != content) { - File(profile.typed.path).writeText(content) - if (profile.id == Settings.selectedProfile) { - selectedProfileUpdated = true - } - } - profile.typed.lastUpdated = Date() - ProfileManager.update(profile) - } catch (e: Exception) { - withContext(Dispatchers.Main) { - errorDialogBuilder(e).show() - } - } - withContext(Dispatchers.Main) { - binding.lastUpdated.text = - DateFormat.getDateTimeInstance().format(profile.typed.lastUpdated) - binding.progressView.isVisible = false - } - if (selectedProfileUpdated) { - runCatching { - Libbox.newStandaloneCommandClient().serviceReload() - } - } - } - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt deleted file mode 100644 index df762eb6e2..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/EditProfileContentActivity.kt +++ /dev/null @@ -1,139 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.core.widget.addTextChangedListener -import androidx.lifecycle.lifecycleScope -import com.blacksquircle.ui.language.json.JsonLanguage -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.databinding.ActivityEditProfileContentBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.unwrap -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File - -class EditProfileContentActivity : AbstractActivity() { - - private var profile: Profile? = null - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_edit_configuration) - binding.editor.language = JsonLanguage() - loadConfiguration() - } - - private fun loadConfiguration() { - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - loadConfiguration0() - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it) - .setPositiveButton(R.string.ok) { _, _ -> finish() } - .show() - } - } - } - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.edit_configutation_menu, menu) - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_undo -> { - if (binding.editor.canUndo()) binding.editor.undo() - return true - } - - R.id.action_redo -> { - if (binding.editor.canRedo()) binding.editor.redo() - return true - } - - R.id.action_check -> { - binding.progressView.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - Libbox.checkConfig(binding.editor.text.toString()) - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it).show() - } - } - withContext(Dispatchers.Main) { - delay(200) - binding.progressView.isInvisible = true - } - } - return true - } - - R.id.action_format -> { - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - val content = Libbox.formatConfig(binding.editor.text.toString()).unwrap - if (binding.editor.text.toString() != content) { - withContext(Dispatchers.Main) { - binding.editor.setTextContent(content) - } - } - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it).show() - } - } - } - return true - } - } - return super.onOptionsItemSelected(item) - } - - private suspend fun loadConfiguration0() { - delay(200L) - - val profileId = intent.getLongExtra("profile_id", -1L) - if (profileId == -1L) error("invalid arguments") - val profile = ProfileManager.get(profileId) ?: error("invalid arguments") - this.profile = profile - val content = File(profile.typed.path).readText() - withContext(Dispatchers.Main) { - binding.editor.setTextContent(content) - binding.editor.addTextChangedListener { - binding.progressView.isVisible = true - val newContent = it.toString() - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - File(profile.typed.path).writeText(newContent) - }.onFailure { - withContext(Dispatchers.Main) { - errorDialogBuilder(it) - .setPositiveButton(android.R.string.ok) { _, _ -> finish() } - .show() - } - } - withContext(Dispatchers.Main) { - delay(200L) - binding.progressView.isInvisible = true - } - } - } - binding.progressView.isInvisible = true - } - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt deleted file mode 100644 index 75672f3a69..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/NewProfileActivity.kt +++ /dev/null @@ -1,216 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.content.Context -import android.net.Uri -import android.os.Bundle -import android.view.View -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.StringRes -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.EnabledType -import io.nekohasekai.sfa.database.Profile -import io.nekohasekai.sfa.database.ProfileManager -import io.nekohasekai.sfa.database.TypedProfile -import io.nekohasekai.sfa.databinding.ActivityAddProfileBinding -import io.nekohasekai.sfa.ktx.addTextChangedListener -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ktx.removeErrorIfNotEmpty -import io.nekohasekai.sfa.ktx.showErrorIfEmpty -import io.nekohasekai.sfa.ktx.startFilesForResult -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.utils.HTTPClient -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.io.InputStream -import java.util.Date - -class NewProfileActivity : AbstractActivity() { - enum class FileSource(@StringRes val formattedRes: Int) { - CreateNew(R.string.profile_source_create_new), - Import(R.string.profile_source_import); - - fun formatted(context: Context): String { - return context.getString(formattedRes) - } - } - - private val importFile = - registerForActivityResult(ActivityResultContracts.GetContent()) { fileURI -> - if (fileURI != null) { - binding.sourceURL.editText?.setText(fileURI.toString()) - } - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_new_profile) - - intent.getStringExtra("importName")?.also { importName -> - intent.getStringExtra("importURL")?.also { importURL -> - binding.name.editText?.setText(importName) - binding.type.text = TypedProfile.Type.Remote.getString(this) - binding.remoteURL.editText?.setText(importURL) - binding.localFields.isVisible = false - binding.remoteFields.isVisible = true - binding.autoUpdateInterval.text = "60" - } - } - - binding.name.removeErrorIfNotEmpty() - binding.type.addTextChangedListener { - when (it) { - TypedProfile.Type.Local.getString(this) -> { - binding.localFields.isVisible = true - binding.remoteFields.isVisible = false - } - - TypedProfile.Type.Remote.getString(this) -> { - binding.localFields.isVisible = false - binding.remoteFields.isVisible = true - if (binding.autoUpdateInterval.text.toIntOrNull() == null) { - binding.autoUpdateInterval.text = "60" - } - } - } - } - binding.fileSourceMenu.addTextChangedListener { - when (it) { - FileSource.CreateNew.formatted(this) -> { - binding.importFileButton.isVisible = false - binding.sourceURL.isVisible = false - } - - FileSource.Import.formatted(this) -> { - binding.importFileButton.isVisible = true - binding.sourceURL.isVisible = true - } - } - } - binding.importFileButton.setOnClickListener { - startFilesForResult(importFile, "application/json") - } - binding.createProfile.setOnClickListener(this::createProfile) - binding.autoUpdateInterval.addTextChangedListener(this::updateAutoUpdateInterval) - } - - private fun createProfile(@Suppress("UNUSED_PARAMETER") view: View) { - if (binding.name.showErrorIfEmpty()) { - return - } - when (binding.type.text) { - TypedProfile.Type.Local.getString(this) -> { - when (binding.fileSourceMenu.text) { - FileSource.Import.formatted(this) -> { - if (binding.sourceURL.showErrorIfEmpty()) { - return - } - } - } - } - - TypedProfile.Type.Remote.getString(this) -> { - if (binding.remoteURL.showErrorIfEmpty()) { - return - } - } - } - binding.progressView.isVisible = true - lifecycleScope.launch(Dispatchers.IO) { - runCatching { - createProfile0() - }.onFailure { e -> - withContext(Dispatchers.Main) { - binding.progressView.isVisible = false - errorDialogBuilder(e).show() - } - } - } - } - - private suspend fun createProfile0() { - val typedProfile = TypedProfile() - val profile = Profile(name = binding.name.text, typed = typedProfile) - profile.userOrder = ProfileManager.nextOrder() - val fileID = ProfileManager.nextFileID() - val configDirectory = File(filesDir, "configs").also { it.mkdirs() } - val configFile = File(configDirectory, "$fileID.json") - typedProfile.path = configFile.path - - when (binding.type.text) { - TypedProfile.Type.Local.getString(this) -> { - typedProfile.type = TypedProfile.Type.Local - - when (binding.fileSourceMenu.text) { - FileSource.CreateNew.formatted(this) -> { - configFile.writeText("{}") - } - - FileSource.Import.formatted(this) -> { - val sourceURL = binding.sourceURL.text - val content = if (sourceURL.startsWith("content://")) { - val inputStream = - contentResolver.openInputStream(Uri.parse(sourceURL)) as InputStream - inputStream.use { it.bufferedReader().readText() } - } else if (sourceURL.startsWith("file://")) { - File(sourceURL).readText() - } else if (sourceURL.startsWith("http://") || sourceURL.startsWith("https://")) { - HTTPClient().use { it.getString(sourceURL) } - } else { - error("unsupported source: $sourceURL") - } - Libbox.checkConfig(content) - configFile.writeText(content) - } - } - } - - TypedProfile.Type.Remote.getString(this) -> { - typedProfile.type = TypedProfile.Type.Remote - val remoteURL = binding.remoteURL.text - val content = HTTPClient().use { it.getString(remoteURL) } - Libbox.checkConfig(content) - configFile.writeText(content) - typedProfile.remoteURL = remoteURL - typedProfile.lastUpdated = Date() - typedProfile.autoUpdate = - EnabledType.valueOf(this, binding.autoUpdate.text).boolValue - binding.autoUpdateInterval.text.toIntOrNull()?.also { - typedProfile.autoUpdateInterval = it - } - } - } - ProfileManager.create(profile) - withContext(Dispatchers.Main) { - binding.progressView.isVisible = false - finish() - } - } - - private fun updateAutoUpdateInterval(newValue: String) { - if (newValue.isBlank()) { - binding.autoUpdateInterval.error = getString(R.string.profile_input_required) - return - } - val intValue = try { - newValue.toInt() - } catch (e: Exception) { - binding.autoUpdateInterval.error = e.localizedMessage - return - } - if (intValue < 15) { - binding.autoUpdateInterval.error = - getString(R.string.profile_auto_update_interval_minimum_hint) - return - } - binding.autoUpdateInterval.error = null - } - - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt deleted file mode 100644 index ce3deddb5f..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/QRScanActivity.kt +++ /dev/null @@ -1,235 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.Manifest -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Bundle -import android.view.Menu -import android.view.MenuItem -import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.core.Camera -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.Preview -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.core.content.ContextCompat -import androidx.core.view.isVisible -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.libbox.Libbox -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.databinding.ActivityQrScanBinding -import io.nekohasekai.sfa.ktx.errorDialogBuilder -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import io.nekohasekai.sfa.vendor.Vendor -import kotlinx.coroutines.launch -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors - -class QRScanActivity : AbstractActivity() { - - private lateinit var analysisExecutor: ExecutorService - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.profile_add_scan_qr_code) - - analysisExecutor = Executors.newSingleThreadExecutor() - binding.previewView.implementationMode = PreviewView.ImplementationMode.COMPATIBLE - binding.previewView.previewStreamState.observe(this) { - if (it === PreviewView.StreamState.STREAMING) { - binding.progress.isVisible = false - binding.previewView.implementationMode = PreviewView.ImplementationMode.PERFORMANCE - } - } - if (ContextCompat.checkSelfPermission( - this, Manifest.permission.CAMERA - ) == PackageManager.PERMISSION_GRANTED - ) { - startCamera() - } else { - requestPermissionLauncher.launch(Manifest.permission.CAMERA) - } - } - - private val requestPermissionLauncher = - registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> - if (isGranted) { - startCamera() - } else { - setResult(RESULT_CANCELED) - finish() - } - } - - private lateinit var imageAnalysis: ImageAnalysis - private lateinit var imageAnalyzer: ImageAnalysis.Analyzer - private val onSuccess: (String) -> Unit = { rawValue: String -> - imageAnalysis.clearAnalyzer() - if (!onSuccess(rawValue)) { - imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) - } - } - private val onFailure: (Exception) -> Unit = { - lifecycleScope.launch { - resetAnalyzer() - errorDialogBuilder("MLKit error: ${it.localizedMessage}").show() - } - } - private val vendorAnalyzer = Vendor.createQRCodeAnalyzer(onSuccess, onFailure) - private var useVendorAnalyzer = vendorAnalyzer != null - private fun resetAnalyzer() { - if (useVendorAnalyzer) { - useVendorAnalyzer = false - imageAnalysis.clearAnalyzer() - imageAnalyzer = ZxingQRCodeAnalyzer(onSuccess, onFailure) - imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) - } - } - - private lateinit var cameraProvider: ProcessCameraProvider - private lateinit var cameraPreview: Preview - private lateinit var camera: Camera - - private fun startCamera() { - val cameraProviderFuture = try { - ProcessCameraProvider.getInstance(this) - } catch (e: Exception) { - fatalError(e) - return - } - cameraProviderFuture.addListener({ - cameraProvider = try { - cameraProviderFuture.get() - } catch (e: Exception) { - fatalError(e) - return@addListener - } - - cameraPreview = Preview.Builder().build() - .also { it.setSurfaceProvider(binding.previewView.surfaceProvider) } - imageAnalysis = ImageAnalysis.Builder().build() - imageAnalyzer = vendorAnalyzer ?: ZxingQRCodeAnalyzer(onSuccess, onFailure) - imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) - cameraProvider.unbindAll() - - try { - camera = cameraProvider.bindToLifecycle( - this, CameraSelector.DEFAULT_BACK_CAMERA, cameraPreview, imageAnalysis - ) - } catch (e: Exception) { - fatalError(e) - } - }, ContextCompat.getMainExecutor(this)) - } - - private fun fatalError(e: Exception) { - lifecycleScope.launch { - errorDialogBuilder(e).setOnDismissListener { - setResult(RESULT_CANCELED) - finish() - }.show() - } - } - - private fun onSuccess(value: String): Boolean { - try { - importRemoteProfileFromString(value) - return true - } catch (e: Exception) { - lifecycleScope.launch { - errorDialogBuilder(e).show() - } - } - return false - } - - private fun importRemoteProfileFromString(uriString: String) { - val uri = Uri.parse(uriString) - if (uri.scheme != "sing-box" || uri.host != "import-remote-profile") error("Not a valid sing-box remote profile URI") - Libbox.parseRemoteProfileImportLink(uri.toString()) - setResult(RESULT_OK, Intent().apply { - setData(uri) - }) - finish() - } - - override fun onPrepareOptionsMenu(menu: Menu?): Boolean { - if (!useVendorAnalyzer) { - menu!!.findItem(R.id.action_use_vendor_analyzer).also { - it.isEnabled = false - it.isChecked = false - } - } - return true - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.qr_scan_menu, menu) - if (useVendorAnalyzer) { - menu.findItem(R.id.action_use_vendor_analyzer).isChecked = true - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_use_front_camera -> { - item.isChecked = !item.isChecked - cameraProvider.unbindAll() - try { - camera = cameraProvider.bindToLifecycle( - this, - if (!item.isChecked) CameraSelector.DEFAULT_BACK_CAMERA else CameraSelector.DEFAULT_FRONT_CAMERA, - cameraPreview, - imageAnalysis - ) - } catch (e: Exception) { - fatalError(e) - } - } - - R.id.action_enable_torch -> { - item.isChecked = !item.isChecked - camera.cameraControl.enableTorch(item.isChecked) - } - - R.id.action_use_vendor_analyzer -> { - item.isChecked = !item.isChecked - imageAnalysis.clearAnalyzer() - imageAnalyzer = if (item.isChecked) { - vendorAnalyzer!! - } else { - ZxingQRCodeAnalyzer(onSuccess, onFailure) - } - imageAnalysis.setAnalyzer(analysisExecutor, imageAnalyzer) - } - - else -> return super.onOptionsItemSelected(item) - } - return true - } - - - override fun onDestroy() { - super.onDestroy() - analysisExecutor.shutdown() - } - - class Contract : ActivityResultContract() { - - override fun createIntent(context: Context, input: Nothing?): Intent = - Intent(context, QRScanActivity::class.java) - - override fun parseResult(resultCode: Int, intent: Intent?): Intent? { - return when (resultCode) { - RESULT_OK -> intent - else -> null - } - } - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt deleted file mode 100644 index 5d35d2707a..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profile/ZxingQRCodeAnalyzer.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.nekohasekai.sfa.ui.profile - -import android.util.Log -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageProxy -import com.google.zxing.BinaryBitmap -import com.google.zxing.NotFoundException -import com.google.zxing.RGBLuminanceSource -import com.google.zxing.common.GlobalHistogramBinarizer -import com.google.zxing.qrcode.QRCodeReader - -class ZxingQRCodeAnalyzer( - private val onSuccess: ((String) -> Unit), - private val onFailure: ((Exception) -> Unit), -) : ImageAnalysis.Analyzer { - - private val qrCodeReader = QRCodeReader() - override fun analyze(image: ImageProxy) { - try { - val bitmap = image.toBitmap() - val intArray = IntArray(bitmap.getWidth() * bitmap.getHeight()) - bitmap.getPixels( - intArray, - 0, - bitmap.getWidth(), - 0, - 0, - bitmap.getWidth(), - bitmap.getHeight() - ) - val source = RGBLuminanceSource(bitmap.getWidth(), bitmap.getHeight(), intArray) - val result = try { - qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source))) - } catch (e: NotFoundException) { - try { - qrCodeReader.decode(BinaryBitmap(GlobalHistogramBinarizer(source.invert()))) - } catch (ignore: NotFoundException) { - return - } - } - Log.d("ZxingQRCodeAnalyzer", "barcode decode success: ${result.text}") - onSuccess(result.text) - } catch (e: Exception) { - onFailure(e) - } finally { - image.close() - } - } -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt deleted file mode 100644 index 6165b3b776..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/PerAppProxyActivity.kt +++ /dev/null @@ -1,782 +0,0 @@ -package io.nekohasekai.sfa.ui.profileoverride - -import android.Manifest -import android.annotation.SuppressLint -import android.content.pm.ApplicationInfo -import android.content.pm.PackageInfo -import android.content.pm.PackageManager -import android.os.Build -import android.os.Bundle -import android.util.Log -import android.view.Gravity -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuItem -import android.view.ViewGroup -import android.widget.Toast -import androidx.appcompat.widget.PopupMenu -import androidx.appcompat.widget.SearchView -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.isVisible -import androidx.core.view.updatePadding -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.RecyclerView -import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.nekohasekai.sfa.Application -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.databinding.ActivityPerAppProxyBinding -import io.nekohasekai.sfa.databinding.DialogProgressbarBinding -import io.nekohasekai.sfa.databinding.ViewAppListItemBinding -import io.nekohasekai.sfa.ktx.clipboardText -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.io.File -import java.util.concurrent.atomic.AtomicInteger -import java.util.zip.ZipFile - -class PerAppProxyActivity : AbstractActivity() { - enum class SortMode { - NAME, PACKAGE_NAME, UID, INSTALL_TIME, UPDATE_TIME, - } - - private var proxyMode = Settings.PER_APP_PROXY_INCLUDE - private var sortMode = SortMode.NAME - private var sortReverse = false - private var hideSystemApps = false - private var hideOfflineApps = true - private var hideDisabledApps = true - - inner class PackageCache( - private val packageInfo: PackageInfo, - private val appInfo: ApplicationInfo, - ) { - - val packageName: String get() = packageInfo.packageName - - val uid get() = packageInfo.applicationInfo!!.uid - - val installTime get() = packageInfo.firstInstallTime - val updateTime get() = packageInfo.lastUpdateTime - val isSystem get() = appInfo.flags and ApplicationInfo.FLAG_SYSTEM == 1 - val isOffline get() = packageInfo.requestedPermissions?.contains(Manifest.permission.INTERNET) != true - val isDisabled get() = appInfo.flags and ApplicationInfo.FLAG_INSTALLED == 0 - - val applicationIcon by lazy { - appInfo.loadIcon(packageManager) - } - - val applicationLabel by lazy { - appInfo.loadLabel(packageManager).toString() - } - } - - private lateinit var adapter: ApplicationAdapter - private var packages = listOf() - private var displayPackages = listOf() - private var currentPackages = listOf() - private var selectedUIDs = mutableSetOf() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_per_app_proxy) - - ViewCompat.setOnApplyWindowInsetsListener(binding.appList) { view, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - view.updatePadding(bottom = insets.bottom) - WindowInsetsCompat.CONSUMED - } - - lifecycleScope.launch { - withContext(Dispatchers.IO) { - proxyMode = if (Settings.perAppProxyMode == Settings.PER_APP_PROXY_INCLUDE) { - Settings.PER_APP_PROXY_INCLUDE - } else { - Settings.PER_APP_PROXY_EXCLUDE - } - withContext(Dispatchers.Main) { - if (proxyMode == Settings.PER_APP_PROXY_INCLUDE) { - binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description) - } else { - binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description) - } - } - reloadApplicationList() - filterApplicationList() - withContext(Dispatchers.Main) { - adapter = ApplicationAdapter(displayPackages) - binding.appList.adapter = adapter - delay(500L) - binding.progress.isVisible = false - } - } - } - } - - private fun reloadApplicationList() { - val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.GET_PERMISSIONS or PackageManager.MATCH_UNINSTALLED_PACKAGES - } else { - @Suppress("DEPRECATION") - PackageManager.GET_PERMISSIONS or PackageManager.GET_UNINSTALLED_PACKAGES - } - val installedPackages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - packageManager.getInstalledPackages( - PackageManager.PackageInfoFlags.of( - packageManagerFlags.toLong() - ) - ) - } else { - @Suppress("DEPRECATION") packageManager.getInstalledPackages(packageManagerFlags) - } - val packages = mutableListOf() - for (packageInfo in installedPackages) { - if (packageInfo.packageName == packageName) continue - val appInfo = packageInfo.applicationInfo ?: continue - packages.add(PackageCache(packageInfo, appInfo)) - } - val selectedPackageNames = Settings.perAppProxyList.toMutableSet() - val selectedUIDs = mutableSetOf() - for (packageCache in packages) { - if (selectedPackageNames.contains(packageCache.packageName)) { - selectedUIDs.add(packageCache.uid) - } - } - this.packages = packages - this.selectedUIDs = selectedUIDs - } - - private fun filterApplicationList(selectedUIDs: Set = this.selectedUIDs) { - val displayPackages = mutableListOf() - for (packageCache in packages) { - if (hideSystemApps && packageCache.isSystem) continue - if (hideOfflineApps && packageCache.isOffline) continue - if (hideDisabledApps && packageCache.isDisabled) continue - displayPackages.add(packageCache) - } - displayPackages.sortWith(compareBy { - !selectedUIDs.contains(it.uid) - }.let { - if (!sortReverse) it.thenBy { - when (sortMode) { - SortMode.NAME -> it.applicationLabel - SortMode.PACKAGE_NAME -> it.packageName - SortMode.UID -> it.uid - SortMode.INSTALL_TIME -> it.installTime - SortMode.UPDATE_TIME -> it.updateTime - } - } else it.thenByDescending { - when (sortMode) { - SortMode.NAME -> it.applicationLabel - SortMode.PACKAGE_NAME -> it.packageName - SortMode.UID -> it.uid - SortMode.INSTALL_TIME -> it.installTime - SortMode.UPDATE_TIME -> it.updateTime - } - } - }) - - this.displayPackages = displayPackages - this.currentPackages = displayPackages - } - - private fun updateApplicationSelection(packageCache: PackageCache, selected: Boolean) { - val performed = if (selected) { - selectedUIDs.add(packageCache.uid) - } else { - selectedUIDs.remove(packageCache.uid) - } - if (!performed) return - currentPackages.forEachIndexed { index, it -> - if (it.uid == packageCache.uid) { - adapter.notifyItemChanged(index, PayloadUpdateSelection(selected)) - } - } - saveSelectedApplications() - } - - data class PayloadUpdateSelection(val selected: Boolean) - - inner class ApplicationAdapter(private var applicationList: List) : - RecyclerView.Adapter() { - - @SuppressLint("NotifyDataSetChanged") - fun setApplicationList(applicationList: List) { - this.applicationList = applicationList - notifyDataSetChanged() - } - - override fun onCreateViewHolder( - parent: ViewGroup, viewType: Int - ): ApplicationViewHolder { - return ApplicationViewHolder( - ViewAppListItemBinding.inflate( - LayoutInflater.from(parent.context), parent, false - ) - ) - } - - override fun getItemCount(): Int { - return applicationList.size - } - - override fun onBindViewHolder( - holder: ApplicationViewHolder, position: Int - ) { - holder.bind(applicationList[position]) - } - - override fun onBindViewHolder( - holder: ApplicationViewHolder, position: Int, payloads: MutableList - ) { - if (payloads.isEmpty()) { - onBindViewHolder(holder, position) - return - } - payloads.forEach { - when (it) { - is PayloadUpdateSelection -> holder.updateSelection(it.selected) - } - } - } - } - - inner class ApplicationViewHolder( - private val binding: ViewAppListItemBinding - ) : RecyclerView.ViewHolder(binding.root) { - - @SuppressLint("SetTextI18n") - fun bind(packageCache: PackageCache) { - binding.appIcon.setImageDrawable(packageCache.applicationIcon) - binding.applicationLabel.text = packageCache.applicationLabel - binding.packageName.text = "${packageCache.packageName} (${packageCache.uid})" - binding.selected.isChecked = selectedUIDs.contains(packageCache.uid) - binding.root.setOnClickListener { - updateApplicationSelection(packageCache, !binding.selected.isChecked) - } - binding.root.setOnLongClickListener { - val popup = PopupMenu(it.context, it) - popup.setForceShowIcon(true) - popup.gravity = Gravity.END - popup.menuInflater.inflate(R.menu.app_menu, popup.menu) - popup.setOnMenuItemClickListener { - when (it.itemId) { - R.id.action_copy_application_label -> { - clipboardText = packageCache.applicationLabel - true - } - - R.id.action_copy_package_name -> { - clipboardText = packageCache.packageName - true - } - - R.id.action_copy_uid -> { - clipboardText = packageCache.uid.toString() - true - } - - else -> false - } - } - popup.show() - true - } - } - - fun updateSelection(selected: Boolean) { - binding.selected.isChecked = selected - } - } - - private fun searchApplications(searchText: String) { - currentPackages = if (searchText.isEmpty()) { - displayPackages - } else { - displayPackages.filter { - it.applicationLabel.contains( - searchText, ignoreCase = true - ) || it.packageName.contains( - searchText, ignoreCase = true - ) || it.uid.toString().contains(searchText) - } - } - adapter.setApplicationList(currentPackages) - } - - override fun onCreateOptionsMenu(menu: Menu?): Boolean { - menuInflater.inflate(R.menu.per_app_menu, menu) - - if (menu != null) { - val searchView = menu.findItem(R.id.action_search).actionView as SearchView - searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String): Boolean { - return true - } - - override fun onQueryTextChange(newText: String): Boolean { - searchApplications(newText) - return true - } - }) - searchView.setOnCloseListener { - searchApplications("") - true - } - when (proxyMode) { - Settings.PER_APP_PROXY_INCLUDE -> { - menu.findItem(R.id.action_mode_include).isChecked = true - } - - Settings.PER_APP_PROXY_EXCLUDE -> { - menu.findItem(R.id.action_mode_exclude).isChecked = true - } - } - when (sortMode) { - SortMode.NAME -> { - menu.findItem(R.id.action_sort_by_name).isChecked = true - } - - SortMode.PACKAGE_NAME -> { - menu.findItem(R.id.action_sort_by_package_name).isChecked = true - } - - SortMode.UID -> { - menu.findItem(R.id.action_sort_by_uid).isChecked = true - } - - SortMode.INSTALL_TIME -> { - menu.findItem(R.id.action_sort_by_install_time).isChecked = true - } - - SortMode.UPDATE_TIME -> { - menu.findItem(R.id.action_sort_by_update_time).isChecked = true - } - } - menu.findItem(R.id.action_sort_reverse).isChecked = sortReverse - menu.findItem(R.id.action_hide_system_apps).isChecked = hideSystemApps - menu.findItem(R.id.action_hide_offline_apps).isChecked = hideOfflineApps - menu.findItem(R.id.action_hide_disabled_apps).isChecked = hideDisabledApps - } - - return super.onCreateOptionsMenu(menu) - } - - @SuppressLint("NotifyDataSetChanged") - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - R.id.action_mode_include -> { - item.isChecked = true - proxyMode = Settings.PER_APP_PROXY_INCLUDE - binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_include_description) - lifecycleScope.launch { - Settings.perAppProxyMode = Settings.PER_APP_PROXY_INCLUDE - } - } - - R.id.action_mode_exclude -> { - item.isChecked = true - proxyMode = Settings.PER_APP_PROXY_EXCLUDE - binding.perAppProxyMode.setText(R.string.per_app_proxy_mode_exclude_description) - lifecycleScope.launch { - Settings.perAppProxyMode = Settings.PER_APP_PROXY_EXCLUDE - } - } - - R.id.action_sort_by_name -> { - item.isChecked = true - sortMode = SortMode.NAME - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_sort_by_package_name -> { - item.isChecked = true - sortMode = SortMode.PACKAGE_NAME - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_sort_by_uid -> { - item.isChecked = true - sortMode = SortMode.UID - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_sort_by_install_time -> { - item.isChecked = true - sortMode = SortMode.INSTALL_TIME - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_sort_by_update_time -> { - item.isChecked = true - sortMode = SortMode.UPDATE_TIME - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_sort_reverse -> { - item.isChecked = !item.isChecked - sortReverse = item.isChecked - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_hide_system_apps -> { - item.isChecked = !item.isChecked - hideSystemApps = item.isChecked - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_hide_offline_apps -> { - item.isChecked = !item.isChecked - hideOfflineApps = item.isChecked - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_hide_disabled_apps -> { - item.isChecked = !item.isChecked - hideDisabledApps = item.isChecked - filterApplicationList() - adapter.setApplicationList(currentPackages) - } - - R.id.action_select_all -> { - val selectedUIDs = mutableSetOf() - currentPackages.forEach { - selectedUIDs.add(it.uid) - } - lifecycleScope.launch { - postSaveSelectedApplications(selectedUIDs) - } - } - - R.id.action_deselect_all -> { - lifecycleScope.launch { - postSaveSelectedApplications(mutableSetOf()) - } - } - - R.id.action_export -> { - lifecycleScope.launch { - val packageList = mutableListOf() - for (packageCache in packages) { - if (selectedUIDs.contains(packageCache.uid)) { - packageList.add(packageCache.packageName) - } - } - clipboardText = packageList.joinToString("\n") - withContext(Dispatchers.Main) { - Toast.makeText( - this@PerAppProxyActivity, - R.string.toast_copied_to_clipboard, - Toast.LENGTH_SHORT - ).show() - } - } - } - - R.id.action_import -> { - val packageNames = clipboardText?.split("\n")?.distinct() - ?.takeIf { it.isNotEmpty() && it[0].isNotEmpty() } - if (packageNames.isNullOrEmpty()) { - Toast.makeText( - this@PerAppProxyActivity, - R.string.toast_clipboard_empty, - Toast.LENGTH_SHORT - ).show() - return true - } - val selectedUIDs = mutableSetOf() - for (packageCache in packages) { - if (packageNames.contains(packageCache.packageName)) { - selectedUIDs.add(packageCache.uid) - } - } - lifecycleScope.launch { - postSaveSelectedApplications(selectedUIDs) - withContext(Dispatchers.Main) { - Toast.makeText( - this@PerAppProxyActivity, - R.string.toast_imported_from_clipboard, - Toast.LENGTH_SHORT - ).show() - } - } - - } - - R.id.action_scan_china_apps -> { - scanChinaApps() - } - - else -> return super.onOptionsItemSelected(item) - } - return true - } - - @SuppressLint("NotifyDataSetChanged") - private fun scanChinaApps() { - val binding = DialogProgressbarBinding.inflate(layoutInflater) - binding.progress.max = currentPackages.size - binding.message.setText(R.string.message_scanning) - val dialogTheme = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && resources.configuration.isNightModeActive) { - com.google.android.material.R.style.Theme_MaterialComponents_Dialog - } else { - com.google.android.material.R.style.Theme_MaterialComponents_Light_Dialog - } - val progress = MaterialAlertDialogBuilder( - this, dialogTheme - ).setView(binding.root).setCancelable(false).create() - progress.show() - lifecycleScope.launch { - val startTime = System.currentTimeMillis() - val foundApps = withContext(Dispatchers.Default) { - mutableMapOf().also { foundApps -> - val progressInt = AtomicInteger() - currentPackages.map { it -> - async { - if (scanChinaPackage(it.packageName)) { - foundApps[it.packageName] = it - } - runOnUiThread { - binding.progress.progress = progressInt.addAndGet(1) - } - } - }.awaitAll() - } - } - Log.d( - "PerAppProxyActivity", - "Scan China apps took ${(System.currentTimeMillis() - startTime).toDouble() / 1000}s" - ) - withContext(Dispatchers.Main) { - progress.dismiss() - if (foundApps.isEmpty()) { - MaterialAlertDialogBuilder(this@PerAppProxyActivity).setTitle(R.string.title_scan_result) - .setMessage(R.string.message_scan_app_no_apps_found) - .setPositiveButton(R.string.ok, null).show() - return@withContext - } - val dialogContent = - getString(R.string.message_scan_app_found) + "\n\n" + foundApps.entries.joinToString( - "\n" - ) { - "${it.value.applicationLabel} (${it.key})" - } - MaterialAlertDialogBuilder(this@PerAppProxyActivity).setTitle(R.string.title_scan_result) - .setMessage(dialogContent) - .setPositiveButton(R.string.action_select) { dialog, _ -> - dialog.dismiss() - lifecycleScope.launch { - val selectedUIDs = selectedUIDs.toMutableSet() - foundApps.values.forEach { - selectedUIDs.add(it.uid) - } - postSaveSelectedApplications(selectedUIDs) - } - }.setNegativeButton(R.string.action_deselect) { dialog, _ -> - dialog.dismiss() - lifecycleScope.launch { - val selectedUIDs = selectedUIDs.toMutableSet() - foundApps.values.forEach { - selectedUIDs.remove(it.uid) - } - postSaveSelectedApplications(selectedUIDs) - } - }.setNeutralButton(android.R.string.cancel, null).show() - } - } - - } - - @SuppressLint("NotifyDataSetChanged") - private suspend fun postSaveSelectedApplications(newUIDs: MutableSet) { - filterApplicationList(newUIDs) - withContext(Dispatchers.Main) { - selectedUIDs = newUIDs - adapter.notifyDataSetChanged() - } - val packageList = selectedUIDs.mapNotNull { uid -> - packages.find { it.uid == uid }?.packageName - } - Settings.perAppProxyList = packageList.toSet() - } - - private fun saveSelectedApplications() { - lifecycleScope.launch { - val packageList = selectedUIDs.mapNotNull { uid -> - packages.find { it.uid == uid }?.packageName - } - Settings.perAppProxyList = packageList.toSet() - } - } - - companion object { - - private val skipPrefixList = listOf( - "com.google", - "com.android.chrome", - "com.android.vending", - "com.microsoft", - "com.apple", - "com.zhiliaoapp.musically", // Banned by China - "com.android.providers.downloads", - ) - - private val chinaAppPrefixList = listOf( - "com.tencent", - "com.alibaba", - "com.umeng", - "com.qihoo", - "com.ali", - "com.alipay", - "com.amap", - "com.sina", - "com.weibo", - "com.vivo", - "com.xiaomi", - "com.huawei", - "com.taobao", - "com.secneo", - "s.h.e.l.l", - "com.stub", - "com.kiwisec", - "com.secshell", - "com.wrapper", - "cn.securitystack", - "com.mogosec", - "com.secoen", - "com.netease", - "com.mx", - "com.qq.e", - "com.baidu", - "com.bytedance", - "com.bugly", - "com.miui", - "com.oppo", - "com.coloros", - "com.iqoo", - "com.meizu", - "com.gionee", - "cn.nubia", - "com.oplus", - "andes.oplus", - "com.unionpay", - "cn.wps" - ) - - - private val chinaAppRegex by lazy { - ("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex() - } - - fun scanChinaPackage(packageName: String): Boolean { - skipPrefixList.forEach { - if (packageName == it || packageName.startsWith("$it.")) return false - } - - val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } else { - @Suppress("DEPRECATION") - PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS - } - if (packageName.matches(chinaAppRegex)) { - Log.d("PerAppProxyActivity", "Match package name: $packageName") - return true - } - try { - val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - Application.packageManager.getPackageInfo( - packageName, - PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong()) - ) - } else { - @Suppress("DEPRECATION") Application.packageManager.getPackageInfo( - packageName, packageManagerFlags - ) - } - val appInfo = packageInfo.applicationInfo ?: return false - packageInfo.services?.forEach { - if (it.name.matches(chinaAppRegex)) { - Log.d("PerAppProxyActivity", "Match service ${it.name} in $packageName") - return true - } - } - packageInfo.activities?.forEach { - if (it.name.matches(chinaAppRegex)) { - Log.d("PerAppProxyActivity", "Match activity ${it.name} in $packageName") - return true - } - } - packageInfo.receivers?.forEach { - if (it.name.matches(chinaAppRegex)) { - Log.d("PerAppProxyActivity", "Match receiver ${it.name} in $packageName") - return true - } - } - packageInfo.providers?.forEach { - if (it.name.matches(chinaAppRegex)) { - Log.d("PerAppProxyActivity", "Match provider ${it.name} in $packageName") - return true - } - } - ZipFile(File(appInfo.publicSourceDir)).use { - for (packageEntry in it.entries()) { - if (packageEntry.name.startsWith("firebase-")) return false - } - for (packageEntry in it.entries()) { - if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith( - ".dex" - )) - ) { - continue - } - if (packageEntry.size > 15000000) { - Log.d( - "PerAppProxyActivity", - "Confirm $packageName due to large dex file" - ) - return true - } - val input = it.getInputStream(packageEntry).buffered() - val dexFile = try { - DexBackedDexFile.fromInputStream(null, input) - } catch (e: Exception) { - Log.e("PerAppProxyActivity", "Error reading dex file", e) - return false - } - for (clazz in dexFile.classes) { - val clazzName = - clazz.type.substring(1, clazz.type.length - 1).replace("/", ".") - .replace("$", ".") - if (clazzName.matches(chinaAppRegex)) { - Log.d("PerAppProxyActivity", "Match $clazzName in $packageName") - return true - } - } - } - } - } catch (e: Exception) { - Log.e("PerAppProxyActivity", "Error scanning package $packageName", e) - } - return false - } - } - -} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt deleted file mode 100644 index 4be88f29a3..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/profileoverride/ProfileOverrideActivity.kt +++ /dev/null @@ -1,58 +0,0 @@ -package io.nekohasekai.sfa.ui.profileoverride - -import android.content.Intent -import android.os.Bundle -import androidx.lifecycle.lifecycleScope -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.constant.PerAppProxyUpdateType -import io.nekohasekai.sfa.database.Settings -import io.nekohasekai.sfa.databinding.ActivityConfigOverrideBinding -import io.nekohasekai.sfa.ktx.addTextChangedListener -import io.nekohasekai.sfa.ktx.setSimpleItems -import io.nekohasekai.sfa.ktx.text -import io.nekohasekai.sfa.ui.shared.AbstractActivity -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -class ProfileOverrideActivity : - AbstractActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setTitle(R.string.title_profile_override) - binding.switchPerAppProxy.isChecked = Settings.perAppProxyEnabled - binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked -> - Settings.perAppProxyEnabled = isChecked - binding.perAppProxyUpdateOnChange.isEnabled = binding.switchPerAppProxy.isChecked - binding.configureAppListButton.isEnabled = isChecked - } - binding.perAppProxyUpdateOnChange.isEnabled = binding.switchPerAppProxy.isChecked - binding.configureAppListButton.isEnabled = binding.switchPerAppProxy.isChecked - - binding.perAppProxyUpdateOnChange.addTextChangedListener { - lifecycleScope.launch(Dispatchers.IO) { - Settings.perAppProxyUpdateOnChange = - PerAppProxyUpdateType.valueOf(this@ProfileOverrideActivity, it).value() - } - } - - binding.configureAppListButton.setOnClickListener { - startActivity(Intent(this, PerAppProxyActivity::class.java)) - } - lifecycleScope.launch(Dispatchers.IO) { - reloadSettings() - } - } - - private suspend fun reloadSettings() { - val perAppUpdateOnChange = Settings.perAppProxyUpdateOnChange - withContext(Dispatchers.Main) { - binding.perAppProxyUpdateOnChange.text = - PerAppProxyUpdateType.valueOf(perAppUpdateOnChange) - .getString(this@ProfileOverrideActivity) - binding.perAppProxyUpdateOnChange.setSimpleItems(R.array.per_app_proxy_update_on_change_value) - } - } -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt deleted file mode 100644 index 92005c1c93..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/AbstractActivity.kt +++ /dev/null @@ -1,87 +0,0 @@ -package io.nekohasekai.sfa.ui.shared - -import android.content.res.Configuration -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.MenuItem -import android.view.WindowManager -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.view.WindowCompat -import androidx.viewbinding.ViewBinding -import com.google.android.material.appbar.MaterialToolbar -import com.google.android.material.color.DynamicColors -import io.nekohasekai.sfa.R -import io.nekohasekai.sfa.ktx.getAttrColor -import io.nekohasekai.sfa.ui.MainActivity -import io.nekohasekai.sfa.utils.MIUIUtils -import java.lang.reflect.ParameterizedType - -abstract class AbstractActivity : AppCompatActivity() { - - private var _binding: Binding? = null - internal val binding get() = _binding!! - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - DynamicColors.applyToActivityIfAvailable(this) - - // Set light navigation bar for Android 8.0 - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) { - val nightFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK - if (nightFlag != Configuration.UI_MODE_NIGHT_YES) { - val insetsController = WindowCompat.getInsetsController( - window, - window.decorView - ) - insetsController.isAppearanceLightNavigationBars = true - } - } - - _binding = createBindingInstance(layoutInflater).also { - setContentView(it.root) - } - - findViewById(R.id.toolbar)?.also { - setSupportActionBar(it) - } - - // MIUI overrides colorSurfaceContainer to colorSurface without below flags - @Suppress("DEPRECATION") if (MIUIUtils.isMIUI) { - window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) - window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION) - } - - if (this !is MainActivity) { - supportActionBar?.setHomeAsUpIndicator(AppCompatResources.getDrawable( - this@AbstractActivity, R.drawable.ic_arrow_back_24 - )!!.apply { - setTint(getAttrColor(com.google.android.material.R.attr.colorOnSurface)) - }) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - } - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean { - when (item.itemId) { - android.R.id.home -> { - onBackPressedDispatcher.onBackPressed() - return true - } - } - return super.onOptionsItemSelected(item) - } - - @Suppress("UNCHECKED_CAST") - private fun createBindingInstance( - inflater: LayoutInflater, - ): Binding { - val vbType = (javaClass.genericSuperclass as ParameterizedType).actualTypeArguments[0] - val vbClass = vbType as Class - val method = vbClass.getMethod("inflate", LayoutInflater::class.java) - return method.invoke(null, inflater) as Binding - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt deleted file mode 100644 index 60b8a2c4c8..0000000000 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/ui/shared/QRCodeDialog.kt +++ /dev/null @@ -1,26 +0,0 @@ -package io.nekohasekai.sfa.ui.shared - -import android.graphics.Bitmap -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import io.nekohasekai.sfa.databinding.FragmentQrcodeDialogBinding - -class QRCodeDialog(private val bitmap: Bitmap) : - BottomSheetDialogFragment() { - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - val binding = FragmentQrcodeDialogBinding.inflate(inflater, container, false) - val behavior = BottomSheetBehavior.from(binding.qrcodeLayout) - behavior.state = BottomSheetBehavior.STATE_EXPANDED - binding.qrCode.setImageBitmap(bitmap) - return binding.root - } - -} \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt new file mode 100644 index 0000000000..63d2b61282 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateCheckException.kt @@ -0,0 +1,5 @@ +package io.nekohasekai.sfa.update + +sealed class UpdateCheckException : Exception() { + class TrackNotSupported : UpdateCheckException() +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt new file mode 100644 index 0000000000..6e3fc4972e --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateInfo.kt @@ -0,0 +1,24 @@ +package io.nekohasekai.sfa.update + +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +data class UpdateInfo( + val versionCode: Int, + val versionName: String, + val downloadUrl: String, + val releaseUrl: String, + val releaseNotes: String?, + val isPrerelease: Boolean, + val fileSize: Long = 0, +) { + fun toJson(): String = Json.encodeToString(this) + + companion object { + fun fromJson(json: String): UpdateInfo? = runCatching { + Json.decodeFromString(json) + }.getOrNull() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt new file mode 100644 index 0000000000..17efa3d654 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateState.kt @@ -0,0 +1,90 @@ +package io.nekohasekai.sfa.update + +import androidx.compose.runtime.mutableStateOf +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.database.Settings +import java.io.File + +object UpdateState { + val hasUpdate = mutableStateOf(false) + val updateInfo = mutableStateOf(null) + val isChecking = mutableStateOf(false) + + val isDownloading = mutableStateOf(false) + val downloadError = mutableStateOf(null) + + val cachedApkFile = mutableStateOf(null) + + sealed class InstallStatus { + data object Idle : InstallStatus() + data object Installing : InstallStatus() + data object Success : InstallStatus() + data class Failed(val error: String) : InstallStatus() + } + + val installStatus = mutableStateOf(InstallStatus.Idle) + + fun setUpdate(info: UpdateInfo?) { + updateInfo.value = info + hasUpdate.value = info != null + saveToCache(info) + } + + fun setInstallStatus(status: InstallStatus) { + installStatus.value = status + } + + fun clear() { + hasUpdate.value = false + updateInfo.value = null + isDownloading.value = false + downloadError.value = null + installStatus.value = InstallStatus.Idle + cachedApkFile.value = null + clearCache() + } + + fun resetDownload() { + isDownloading.value = false + downloadError.value = null + } + + fun loadFromCache() { + val json = Settings.cachedUpdateInfo + if (json.isBlank()) return + + val info = UpdateInfo.fromJson(json) ?: return + if (info.versionCode <= BuildConfig.VERSION_CODE) { + clearCache() + return + } + + updateInfo.value = info + hasUpdate.value = true + + val apkPath = Settings.cachedApkPath + if (apkPath.isNotBlank()) { + val apkFile = File(apkPath) + if (apkFile.exists() && apkFile.length() > 0) { + cachedApkFile.value = apkFile + } else { + Settings.cachedApkPath = "" + } + } + } + + private fun saveToCache(info: UpdateInfo?) { + Settings.cachedUpdateInfo = info?.toJson() ?: "" + } + + fun saveApkPath(file: File) { + Settings.cachedApkPath = file.absolutePath + cachedApkFile.value = file + } + + private fun clearCache() { + Settings.cachedUpdateInfo = "" + Settings.cachedApkPath = "" + Settings.lastShownUpdateVersion = 0 + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt new file mode 100644 index 0000000000..d3e1c51767 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/update/UpdateTrack.kt @@ -0,0 +1,14 @@ +package io.nekohasekai.sfa.update + +enum class UpdateTrack { + STABLE, + BETA, + ; + + companion object { + fun fromString(value: String): UpdateTrack = when (value.lowercase()) { + "beta" -> BETA + else -> STABLE + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/AppLifecycleObserver.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/AppLifecycleObserver.kt new file mode 100644 index 0000000000..9a2969fb42 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/AppLifecycleObserver.kt @@ -0,0 +1,54 @@ +package io.nekohasekai.sfa.utils + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.PowerManager +import androidx.core.content.getSystemService +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object AppLifecycleObserver : DefaultLifecycleObserver { + private val _isForeground = MutableStateFlow(true) + val isForeground: StateFlow = _isForeground.asStateFlow() + + private val _isScreenOn = MutableStateFlow(true) + val isScreenOn: StateFlow = _isScreenOn.asStateFlow() + + private val screenReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_SCREEN_ON -> _isScreenOn.value = true + Intent.ACTION_SCREEN_OFF -> _isScreenOn.value = false + } + } + } + + fun register(context: Context) { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + val powerManager = context.getSystemService()!! + _isScreenOn.value = powerManager.isInteractive + + context.registerReceiver( + screenReceiver, + IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }, + ) + } + + override fun onStart(owner: LifecycleOwner) { + _isForeground.value = true + } + + override fun onStop(owner: LifecycleOwner) { + _isForeground.value = false + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt index 8d03180820..a9808d108f 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ColorUtils.kt @@ -14,7 +14,6 @@ import io.nekohasekai.sfa.R import java.util.Stack object ColorUtils { - private val ansiRegex by lazy { Regex("\u001B\\[[;\\d]*m") } fun ansiEscapeToSpannable(context: Context, text: String): Spannable { @@ -33,11 +32,12 @@ object ColorUtils { if (ansiInstruction.decorationCode == "0" && stack.isNotEmpty()) { spans.add(stack.pop().copy(end = end - offset)) } else { - val span = AnsiSpan( - AnsiInstruction(context, stringCode), - start - if (offset > start) start else offset - 1, - 0 - ) + val span = + AnsiSpan( + AnsiInstruction(context, stringCode), + start - if (offset > start) start else offset - 1, + 0, + ) stack.push(span) } } @@ -48,7 +48,7 @@ object ColorUtils { it, ansiSpan.start, ansiSpan.end, - Spannable.SPAN_EXCLUSIVE_INCLUSIVE + Spannable.SPAN_EXCLUSIVE_INCLUSIVE, ) } } @@ -56,15 +56,13 @@ object ColorUtils { return spannable } - private data class AnsiSpan( - val instruction: AnsiInstruction, val start: Int, val end: Int - ) + private data class AnsiSpan(val instruction: AnsiInstruction, val start: Int, val end: Int) private class AnsiInstruction(context: Context, code: String) { - val spans: List by lazy { listOfNotNull( - getSpan(colorCode, context), getSpan(decorationCode, context) + getSpan(colorCode, context), + getSpan(decorationCode, context), ) } diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt index 4b6c3bb2aa..c5b7681f00 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/CommandClient.kt @@ -1,83 +1,107 @@ 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.Connections +import io.nekohasekai.libbox.ConnectionEvents import io.nekohasekai.libbox.Libbox +import io.nekohasekai.libbox.LogEntry +import io.nekohasekai.libbox.LogIterator import io.nekohasekai.libbox.OutboundGroup 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.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch open class CommandClient( private val scope: CoroutineScope, - private val connectionType: ConnectionType, + private val connectionTypes: List, private val handler: Handler, ) { + constructor( + scope: CoroutineScope, + connectionType: ConnectionType, + handler: Handler, + ) : this(scope, listOf(connectionType), handler) + + private val additionalHandlers = mutableListOf() + private var cachedGroups: MutableList? = null + + fun addHandler(handler: Handler) { + synchronized(additionalHandlers) { + if (!additionalHandlers.contains(handler)) { + additionalHandlers.add(handler) + cachedGroups?.let { groups -> + handler.updateGroups(groups) + } + } + } + } + + fun removeHandler(handler: Handler) { + synchronized(additionalHandlers) { + additionalHandlers.remove(handler) + } + } + + private fun getAllHandlers(): List = synchronized(additionalHandlers) { + listOf(handler) + additionalHandlers + } enum class ConnectionType { - Status, Groups, Log, ClashMode + Status, + Groups, + Log, + ClashMode, + Connections, } interface Handler { - fun onConnected() {} + fun onDisconnected() {} fun updateStatus(status: StatusMessage) {} + fun setDefaultLogLevel(level: Int) {} + fun clearLogs() {} - fun appendLogs(message: List) {} + + fun appendLogs(message: List) {} fun updateGroups(newGroups: MutableList) {} fun initializeClashMode(modeList: List, currentMode: String) {} + fun updateClashMode(newMode: String) {} + fun writeConnectionEvents(events: ConnectionEvents) {} } private var commandClient: CommandClient? = null private val clientHandler = ClientHandler() + fun connect() { disconnect() val options = CommandClientOptions() - options.command = when (connectionType) { - ConnectionType.Status -> Libbox.CommandStatus - ConnectionType.Groups -> Libbox.CommandGroup - ConnectionType.Log -> Libbox.CommandLog - ConnectionType.ClashMode -> Libbox.CommandClashMode + 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) } options.statusInterval = 1 * 1000 * 1000 * 1000 val commandClient = CommandClient(clientHandler, options) - scope.launch(Dispatchers.IO) { - for (i in 1..10) { - delay(100 + i.toLong() * 50) - try { - commandClient.connect() - } catch (ignored: Exception) { - continue - } - if (!isActive) { - runCatching { - commandClient.disconnect() - } - return@launch - } - this@CommandClient.commandClient = commandClient - return@launch - } - runCatching { - commandClient.disconnect() - } - } + commandClient.connect() + this.commandClient = commandClient } fun disconnect() { @@ -85,19 +109,20 @@ open class CommandClient( runCatching { disconnect() } - Seq.destroyRef(refnum) +// Seq.destroyRef(refnum) } commandClient = null } private inner class ClientHandler : CommandClientHandler { - override fun connected() { - handler.onConnected() + getAllHandlers().forEach { it.onConnected() } + Log.d("CommandClient", "connected") } override fun disconnected(message: String?) { - handler.onDisconnected() + getAllHandlers().forEach { it.onDisconnected() } + Log.d("CommandClient", "disconnected: $message") } override fun writeGroups(message: OutboundGroupIterator?) { @@ -108,34 +133,42 @@ open class CommandClient( while (message.hasNext()) { groups.add(message.next()) } - handler.updateGroups(groups) + cachedGroups = groups + getAllHandlers().forEach { it.updateGroups(groups) } + } + + override fun setDefaultLogLevel(level: Int) { + getAllHandlers().forEach { it.setDefaultLogLevel(level) } } override fun clearLogs() { - handler.clearLogs() + getAllHandlers().forEach { it.clearLogs() } } - override fun writeLogs(messageList: StringIterator?) { + override fun writeLogs(messageList: LogIterator?) { if (messageList == null) { return } - handler.appendLogs(messageList.toList()) + val logs = messageList.toList() + getAllHandlers().forEach { it.appendLogs(logs) } } override fun writeStatus(message: StatusMessage) { - handler.updateStatus(message) + getAllHandlers().forEach { it.updateStatus(message) } } override fun initializeClashMode(modeList: StringIterator, currentMode: String) { - handler.initializeClashMode(modeList.toList(), currentMode) + val modes = modeList.toList() + getAllHandlers().forEach { it.initializeClashMode(modes, currentMode) } } override fun updateClashMode(newMode: String) { - handler.updateClashMode(newMode) + getAllHandlers().forEach { it.updateClashMode(newMode) } } - override fun writeConnections(message: Connections?) { + override fun writeConnectionEvents(events: ConnectionEvents?) { + if (events == null) return + getAllHandlers().forEach { it.writeConnectionEvents(events) } } } - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt new file mode 100644 index 0000000000..38396f330d --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/ConnectivityBinderUtils.kt @@ -0,0 +1,45 @@ +package io.nekohasekai.sfa.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.os.IBinder +import android.os.Parcel +import android.util.Log + +object ConnectivityBinderUtils { + private const val TAG = "ConnectivityBinderUtils" + + fun getBinder(context: Context): IBinder? { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return null + try { + val field = cm.javaClass.getDeclaredField("mService") + field.isAccessible = true + val service = field.get(cm) as? android.os.IInterface + if (service != null) { + return service.asBinder() + } + } catch (e: Throwable) { + Log.w(TAG, "Failed to get ConnectivityManager service binder", e) + } + return try { + val serviceManager = Class.forName("android.os.ServiceManager") + val getService = serviceManager.getMethod("getService", String::class.java) + getService.invoke(null, Context.CONNECTIVITY_SERVICE) as? IBinder + } catch (e: Throwable) { + Log.w(TAG, "Failed to get binder from ServiceManager", e) + null + } + } + + inline fun withParcel(block: (data: Parcel, reply: Parcel) -> T): T { + val data = Parcel.obtain() + val reply = Parcel.obtain() + return try { + block(data, reply) + } finally { + reply.recycle() + data.recycle() + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt index 64f785b268..5bdaef4817 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HTTPClient.kt @@ -7,7 +7,6 @@ import java.io.Closeable import java.util.Locale class HTTPClient : Closeable { - companion object { val userAgent by lazy { var userAgent = "SFA/" @@ -40,6 +39,4 @@ class HTTPClient : Closeable { override fun close() { client.close() } - - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt new file mode 100644 index 0000000000..0d1176ec44 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookErrorClient.kt @@ -0,0 +1,48 @@ +package io.nekohasekai.sfa.utils + +import android.content.Context +import android.os.RemoteException +import io.nekohasekai.sfa.bg.LogEntry +import io.nekohasekai.sfa.bg.ParceledListSlice +import io.nekohasekai.sfa.xposed.HookStatusKeys + +object HookErrorClient { + enum class Failure { + SERVICE_UNAVAILABLE, + TRANSACTION_FAILED, + REMOTE_ERROR, + PROTOCOL_ERROR, + } + + data class Result(val logs: List, val hasWarnings: Boolean, val failure: Failure? = null, val detail: String? = null) + + private fun failureResult(failure: Failure, detail: String? = null) = Result( + logs = emptyList(), + hasWarnings = false, + failure = failure, + detail = detail, + ) + + fun query(context: Context): Result { + val binder = ConnectivityBinderUtils.getBinder(context) + ?: return failureResult(Failure.SERVICE_UNAVAILABLE) + return ConnectivityBinderUtils.withParcel { data, reply -> + data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR) + if (!binder.transact(HookStatusKeys.TRANSACTION_GET_ERRORS, data, reply, 0)) { + return@withParcel failureResult(Failure.TRANSACTION_FAILED) + } + try { + reply.readException() + } catch (e: RemoteException) { + return@withParcel failureResult(Failure.REMOTE_ERROR, e.message) + } + if (reply.dataAvail() < 4) { + return@withParcel failureResult(Failure.PROTOCOL_ERROR, "reply too short: ${reply.dataAvail()}") + } + val hasWarnings = reply.readInt() != 0 + val slice = ParceledListSlice.CREATOR.createFromParcel(reply, LogEntry::class.java.classLoader) + @Suppress("UNCHECKED_CAST") + Result(logs = slice.list as List, hasWarnings = hasWarnings) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt new file mode 100644 index 0000000000..45e88be46a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookModuleUpdateNotifier.kt @@ -0,0 +1,77 @@ +package io.nekohasekai.sfa.utils + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import io.nekohasekai.sfa.R +import io.nekohasekai.sfa.bg.ServiceNotification +import io.nekohasekai.sfa.compose.MainActivity +import io.nekohasekai.sfa.xposed.HookModuleVersion + +object HookModuleUpdateNotifier { + private const val CHANNEL_ID = "lsposed_module_update" + private const val NOTIFICATION_ID = 0x5F10 + + fun needsRestart(status: HookStatusClient.Status?): Boolean = isDowngrade(status) || isUpgrade(status) + + fun isDowngrade(status: HookStatusClient.Status?): Boolean = status != null && status.version > HookModuleVersion.CURRENT + + fun isUpgrade(status: HookStatusClient.Status?): Boolean = status != null && status.version < HookModuleVersion.CURRENT + + fun sync(context: Context) { + HookStatusClient.refresh() + maybeNotify(context, HookStatusClient.status.value) + } + + fun maybeNotify(context: Context, status: HookStatusClient.Status?) { + if (!needsRestart(status)) { + cancel(context) + return + } + ensureChannel(context) + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + addCategory("de.robv.android.xposed.category.MODULE_SETTINGS") + } + val pendingIntent = + PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or ServiceNotification.flags, + ) + val builder = + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_menu) + .setContentTitle(context.getString(R.string.privilege_module_restart_notification_title)) + .setContentText(context.getString(R.string.privilege_module_restart_notification_message)) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_STATUS) + .setPriority(NotificationCompat.PRIORITY_HIGH) + NotificationManagerCompat.from(context).notify(NOTIFICATION_ID, builder.build()) + } + + private fun cancel(context: Context) { + NotificationManagerCompat.from(context).cancel(NOTIFICATION_ID) + } + + private fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + context.getString(R.string.privilege_module_restart_channel), + NotificationManager.IMPORTANCE_HIGH, + ), + ) + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt new file mode 100644 index 0000000000..e0bd730eb6 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/HookStatusClient.kt @@ -0,0 +1,61 @@ +package io.nekohasekai.sfa.utils + +import android.content.Context +import android.content.pm.PackageInfo +import io.nekohasekai.sfa.bg.ParceledListSlice +import io.nekohasekai.sfa.xposed.HookStatusKeys +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +object HookStatusClient { + data class Status(val active: Boolean, val lastPatchedAt: Long, val version: Int, val systemPid: Int) + + private val statusFlow = MutableStateFlow(null) + val status: StateFlow = statusFlow + + @Volatile + private var appContext: Context? = null + + fun register(context: Context) { + appContext = context.applicationContext + refresh() + } + + fun refresh() { + val context = appContext ?: return + val binder = ConnectivityBinderUtils.getBinder(context) ?: run { + statusFlow.value = null + return + } + ConnectivityBinderUtils.withParcel { data, reply -> + data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR) + val ok = binder.transact(HookStatusKeys.TRANSACTION_STATUS, data, reply, 0) + if (!ok) { + statusFlow.value = null + return + } + reply.readException() + statusFlow.value = Status( + active = reply.readInt() != 0, + lastPatchedAt = reply.readLong(), + version = reply.readInt(), + systemPid = reply.readInt(), + ) + } + } + + fun getInstalledPackages(context: Context, flags: Long, userId: Int): List? { + val binder = ConnectivityBinderUtils.getBinder(context) ?: return null + return ConnectivityBinderUtils.withParcel { data, reply -> + data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR) + data.writeLong(flags) + data.writeInt(userId) + val ok = binder.transact(HookStatusKeys.TRANSACTION_GET_INSTALLED_PACKAGES, data, reply, 0) + if (!ok) return@withParcel null + reply.readException() + val slice = ParceledListSlice.CREATOR.createFromParcel(reply, PackageInfo::class.java.classLoader) + @Suppress("UNCHECKED_CAST") + (slice as ParceledListSlice).list + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt index 37eae60780..ebc678eafe 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/MIUIUtils.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.os.Process object MIUIUtils { - val isMIUI by lazy { !getSystemProperty("ro.miui.ui.version.name").isNullOrBlank() } @@ -27,5 +26,4 @@ object MIUIUtils { intent.putExtra("extra_pkgname", context.packageName) context.startActivity(intent) } - -} \ No newline at end of file +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt new file mode 100644 index 0000000000..283e714ebe --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/PrivilegeSettingsClient.kt @@ -0,0 +1,68 @@ +package io.nekohasekai.sfa.utils + +import android.content.Context +import android.os.RemoteException +import android.util.Log +import io.nekohasekai.sfa.bg.PackageEntry +import io.nekohasekai.sfa.bg.ParceledListSlice +import io.nekohasekai.sfa.bg.RootClient +import io.nekohasekai.sfa.database.Settings +import io.nekohasekai.sfa.xposed.HookModuleVersion +import io.nekohasekai.sfa.xposed.HookStatusKeys + +object PrivilegeSettingsClient { + private const val TAG = "PrivilegeSettingsClient" + + @Volatile + private var appContext: Context? = null + + data class ExportResult(val outputPath: String?, val error: String?) + + fun register(context: Context) { + appContext = context.applicationContext + sync() + } + + fun sync(): Throwable? { + val context = appContext ?: return null + if (isVersionMismatch()) return null + val binder = ConnectivityBinderUtils.getBinder(context) ?: return null + return ConnectivityBinderUtils.withParcel { data, reply -> + data.writeInterfaceToken(HookStatusKeys.DESCRIPTOR) + data.writeInt(if (Settings.privilegeSettingsEnabled) 1 else 0) + ParceledListSlice(Settings.privilegeSettingsList.map { PackageEntry(it) }).writeToParcel(data, 0) + data.writeInt(if (Settings.privilegeSettingsInterfaceRenameEnabled) 1 else 0) + data.writeString(Settings.privilegeSettingsInterfacePrefix) + try { + val ok = binder.transact(HookStatusKeys.TRANSACTION_UPDATE_PRIVILEGE_SETTINGS, data, reply, 0) + reply.readException() + if (!ok) { + val error = RemoteException() + Log.w(TAG, "Privilege settings sync failed: transaction not handled", error) + return@withParcel error + } + return@withParcel null + } catch (e: RemoteException) { + Log.w(TAG, "Privilege settings sync failed: remote exception", e) + return@withParcel e + } catch (e: RuntimeException) { + Log.w(TAG, "Privilege settings sync failed: bad reply", e) + return@withParcel e + } + } + } + + suspend fun exportDebugInfo(outputPath: String): ExportResult = try { + val service = RootClient.bindService() + val path = service.exportDebugInfo(outputPath) + ExportResult(path, null) + } catch (e: Throwable) { + Log.e(TAG, "Export debug info failed", e) + ExportResult(null, e.message ?: "export failed") + } + + private fun isVersionMismatch(): Boolean { + val status = HookStatusClient.status.value ?: return false + return status.version != HookModuleVersion.CURRENT + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt new file mode 100644 index 0000000000..f210ed10cc --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/utils/VpnDetectionTest.kt @@ -0,0 +1,162 @@ +package io.nekohasekai.sfa.utils + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import java.net.NetworkInterface +import java.util.Collections + +data class DetectionResult( + val frameworkDetected: List, + val nativeDetected: Boolean, + val frameworkInterfaces: List, + val nativeInterfaces: List, + val httpProxy: String?, +) + +object VpnDetectionTest { + + fun runDetection(context: Context): DetectionResult { + val frameworkDetected = LinkedHashSet() + val frameworkInterfaces = LinkedHashSet() + + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return DetectionResult(emptyList(), false, emptyList(), emptyList(), null) + + // Check activeNetworkInfo + val activeInfo = cm.activeNetworkInfo + if (activeInfo?.type == ConnectivityManager.TYPE_VPN) { + frameworkDetected += "ActiveNetworkInfo" + } + + // Check networkInfo(TYPE_VPN) + val vpnInfo = cm.getNetworkInfo(ConnectivityManager.TYPE_VPN) + if (vpnInfo != null && vpnInfo.isConnected) { + frameworkDetected += "NetworkInfo" + } + + // Check networkForType(VPN) + val vpnNetwork = runCatching { + val method = cm.javaClass.getMethod( + "getNetworkForType", + Int::class.javaPrimitiveType, + ) + method.invoke(cm, ConnectivityManager.TYPE_VPN) as? Network + }.getOrNull() + if (vpnNetwork != null) { + frameworkDetected += "NetworkForType" + } + + // Check all networks for VPN transport or missing NOT_VPN capability + val networks = cm.allNetworks ?: emptyArray() + for (network in networks) { + val caps = cm.getNetworkCapabilities(network) ?: continue + if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { + frameworkDetected += "NetworkCapabilities" + } + if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)) { + frameworkDetected += "NetworkCapabilities" + } + // Check interface name in LinkProperties + val lp = cm.getLinkProperties(network) + if (isVpnInterface(lp?.interfaceName)) { + lp?.interfaceName?.let(frameworkInterfaces::add) + frameworkDetected += "LinkProperties" + } + } + + // Check activeLinkProperties interface + val activeLinkProperties = runCatching { cm.getLinkProperties(cm.activeNetwork) }.getOrNull() + if (isVpnInterface(activeLinkProperties?.interfaceName)) { + activeLinkProperties?.interfaceName?.let(frameworkInterfaces::add) + frameworkDetected += "LinkProperties" + } + + // Native: Check network interfaces (getifaddrs) + val nativeInterfaces = checkNetworkInterfaces() + + val httpProxy = readHttpProxy(cm) + return DetectionResult( + frameworkDetected.toList(), + nativeInterfaces.isNotEmpty(), + frameworkInterfaces.toList(), + nativeInterfaces, + httpProxy, + ) + } + + private fun checkNetworkInterfaces(): List { + val list = try { + Collections.list(NetworkInterface.getNetworkInterfaces()) + } catch (_: Throwable) { + return emptyList() + } + val matches = ArrayList() + for (iface in list) { + val name = iface.name ?: continue + val isUp = runCatching { iface.isUp }.getOrElse { false } + if (!isUp) continue + if (isVpnInterface(name)) { + matches.add(name) + } + } + return matches + } + + private fun isVpnInterface(name: String?): Boolean { + if (name.isNullOrEmpty()) return false + val lower = name.lowercase() + return lower.startsWith("tun") || lower.startsWith("ppp") || lower.startsWith("tap") + } + + private fun readHttpProxy(cm: ConnectivityManager): String? { + val defaultProxy = try { + val method = cm.javaClass.getMethod("getDefaultProxy") + method.invoke(cm) as? android.net.ProxyInfo + } catch (_: Throwable) { + null + } + val activeLinkProperties = runCatching { cm.getLinkProperties(cm.activeNetwork) }.getOrNull() + val networks = cm.allNetworks ?: emptyArray() + val proxies = buildList { + add(formatProxyInfo(defaultProxy)) + add(formatProxyInfo(readProxyFromLinkProperties(activeLinkProperties))) + for (network in networks) { + add(formatProxyInfo(readProxyFromLinkProperties(cm.getLinkProperties(network)))) + } + } + return proxies.firstOrNull { !it.isNullOrEmpty() } + } + + private fun readProxyFromLinkProperties(lp: android.net.LinkProperties?): android.net.ProxyInfo? { + if (lp == null) return null + return try { + val method = lp.javaClass.getMethod("getHttpProxy") + method.invoke(lp) as? android.net.ProxyInfo + } catch (_: Throwable) { + try { + val field = lp.javaClass.getDeclaredField("mHttpProxy") + field.isAccessible = true + field.get(lp) as? android.net.ProxyInfo + } catch (_: Throwable) { + null + } + } + } + + private fun formatProxyInfo(proxyInfo: android.net.ProxyInfo?): String? { + if (proxyInfo == null) return null + return try { + val host = proxyInfo.host + val port = proxyInfo.port + if (!host.isNullOrEmpty() && port > 0) { + return "$host:$port" + } + val pac = proxyInfo.pacFileUrl?.toString() + if (!pac.isNullOrEmpty()) pac else null + } catch (_: Throwable) { + null + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt new file mode 100644 index 0000000000..01e7ba2439 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PackageQueryStrategy.kt @@ -0,0 +1,7 @@ +package io.nekohasekai.sfa.vendor + +sealed class PackageQueryStrategy { + data object ForcedRoot : PackageQueryStrategy() + data class UserSelected(val mode: String) : PackageQueryStrategy() + data object Direct : PackageQueryStrategy() +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedAccessRequiredException.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedAccessRequiredException.kt new file mode 100644 index 0000000000..f582c4d169 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedAccessRequiredException.kt @@ -0,0 +1,3 @@ +package io.nekohasekai.sfa.vendor + +class PrivilegedAccessRequiredException(message: String) : Exception(message) diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt new file mode 100644 index 0000000000..79a051629b --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/PrivilegedServiceUtils.kt @@ -0,0 +1,183 @@ +package io.nekohasekai.sfa.vendor + +import android.content.IIntentSender +import android.content.Intent +import android.content.IntentSender +import android.content.pm.IPackageInstaller +import android.content.pm.IPackageInstallerSession +import android.content.pm.PackageInfo +import android.content.pm.PackageInstaller +import android.os.Build +import android.os.Bundle +import android.os.IBinder +import android.os.ParcelFileDescriptor +import android.system.Os +import io.nekohasekai.sfa.BuildConfig +import java.io.IOException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +object PrivilegedServiceUtils { + + private val iPackageManagerStubClass by lazy { Class.forName("android.content.pm.IPackageManager\$Stub") } + private val asInterfaceMethod by lazy { iPackageManagerStubClass.getMethod("asInterface", IBinder::class.java) } + private val iPackageManagerClass by lazy { Class.forName("android.content.pm.IPackageManager") } + + private val getInstalledPackagesMethodLong by lazy { + iPackageManagerClass.getMethod( + "getInstalledPackages", + Long::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) + } + private val getInstalledPackagesMethodInt by lazy { + iPackageManagerClass.getMethod( + "getInstalledPackages", + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) + } + private val getPackageInstallerMethod by lazy { iPackageManagerClass.getMethod("getPackageInstaller") } + + private val packageInstallerCtorS by lazy { + PackageInstaller::class.java.getConstructor( + IPackageInstaller::class.java, + String::class.java, + String::class.java, + Int::class.javaPrimitiveType, + ) + } + private val packageInstallerCtorPre by lazy { + PackageInstaller::class.java.getConstructor( + IPackageInstaller::class.java, + String::class.java, + Int::class.javaPrimitiveType, + ) + } + private val sessionCtor by lazy { + PackageInstaller.Session::class.java.getConstructor(IPackageInstallerSession::class.java) + } + private val intentSenderCtor by lazy { + IntentSender::class.java.getConstructor(IIntentSender::class.java) + } + private val installFlagsField by lazy { + PackageInstaller.SessionParams::class.java.getDeclaredField("installFlags").apply { isAccessible = true } + } + private val getListMethod by lazy { + Class.forName("android.content.pm.ParceledListSlice").getMethod("getList") + } + + private fun getPackageManager(): Any { + val binder = SystemServiceHelperCompat.getSystemService("package") + ?: throw IllegalStateException("package service not available") + return asInterfaceMethod.invoke(null, binder) + ?: throw IllegalStateException("IPackageManager is null") + } + + fun getInstalledPackages(flags: Int, userId: Int): List { + val iPackageManager = getPackageManager() + val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getInstalledPackagesMethodLong.invoke(iPackageManager, flags.toLong(), userId) + } else { + getInstalledPackagesMethodInt.invoke(iPackageManager, flags, userId) + } + return extractPackageList(result) + } + + fun installPackage(apkFd: ParcelFileDescriptor, size: Long, userId: Int) { + val iPackageInstaller = getPackageInstaller() + val isRoot = Os.getuid() == 0 + val installerPackageName = if (isRoot) BuildConfig.APPLICATION_ID else "com.android.shell" + val targetUserId = if (isRoot) userId else 0 + + val packageInstaller = createPackageInstaller( + iPackageInstaller, + installerPackageName, + null, + targetUserId, + ) + + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + params.setAppPackageName(BuildConfig.APPLICATION_ID) + installFlagsField.setInt(params, installFlagsField.getInt(params) or 2) + val sessionId = packageInstaller.createSession(params) + + val iSession = IPackageInstallerSession.Stub.asInterface( + iPackageInstaller.openSession(sessionId).asBinder(), + ) + val session = createSession(iSession) + + try { + ParcelFileDescriptor.AutoCloseInputStream(apkFd).use { inputStream -> + session.openWrite("base.apk", 0, size).use { outputStream -> + inputStream.copyTo(outputStream) + session.fsync(outputStream) + } + } + + val resultIntent = arrayOfNulls(1) + val latch = CountDownLatch(1) + + val intentSender = createIntentSender { intent -> + resultIntent[0] = intent + latch.countDown() + } + + session.commit(intentSender) + latch.await(60, TimeUnit.SECONDS) + + val intent = resultIntent[0] ?: throw IOException("Installation timed out") + + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + if (status != PackageInstaller.STATUS_SUCCESS) { + val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + throw IOException("Installation failed ($status): $message") + } + } finally { + session.close() + } + } + + private fun getPackageInstaller(): IPackageInstaller { + val iPackageManager = getPackageManager() + val installer = getPackageInstallerMethod.invoke(iPackageManager) as IPackageInstaller + return IPackageInstaller.Stub.asInterface(installer.asBinder()) + } + + private fun createPackageInstaller( + installer: IPackageInstaller, + installerPackageName: String, + installerAttributionTag: String?, + userId: Int, + ): PackageInstaller = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + packageInstallerCtorS.newInstance(installer, installerPackageName, installerAttributionTag, userId) + } else { + packageInstallerCtorPre.newInstance(installer, installerPackageName, userId) + } + + private fun createSession(session: IPackageInstallerSession): PackageInstaller.Session = sessionCtor.newInstance(session) + + private fun createIntentSender(onResult: (Intent) -> Unit): IntentSender { + val sender = object : IIntentSender.Stub() { + override fun send( + code: Int, + intent: Intent, + resolvedType: String?, + whitelistToken: android.os.IBinder?, + finishedReceiver: android.content.IIntentReceiver?, + requiredPermission: String?, + options: Bundle?, + ) { + onResult(intent) + } + } + return intentSenderCtor.newInstance(sender) + } + + @Suppress("UNCHECKED_CAST") + private fun extractPackageList(parceledListSlice: Any?): List { + if (parceledListSlice == null) return emptyList() + val list = getListMethod.invoke(parceledListSlice) as? List<*> + return list?.filterIsInstance() ?: emptyList() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt new file mode 100644 index 0000000000..f6a9db99bb --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/SystemServiceHelperCompat.kt @@ -0,0 +1,33 @@ +package io.nekohasekai.sfa.vendor + +import android.annotation.SuppressLint +import android.os.IBinder +import android.util.Log +import java.lang.reflect.Method + +@SuppressLint("PrivateApi") +object SystemServiceHelperCompat { + + private val serviceCache = HashMap() + private val getService: Method? = try { + val cls = Class.forName("android.os.ServiceManager") + cls.getMethod("getService", String::class.java) + } catch (e: Exception) { + Log.w("SystemServiceHelper", Log.getStackTraceString(e)) + null + } + + fun getSystemService(name: String): IBinder? { + if (serviceCache.containsKey(name)) { + return serviceCache[name] + } + val binder = try { + getService?.invoke(null, name) as? IBinder + } catch (e: Exception) { + Log.w("SystemServiceHelper", Log.getStackTraceString(e)) + null + } + serviceCache[name] = binder + return binder + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt index 718896994f..e72e00ceec 100644 --- a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/vendor/VendorInterface.kt @@ -2,12 +2,65 @@ package io.nekohasekai.sfa.vendor import android.app.Activity import androidx.camera.core.ImageAnalysis +import io.nekohasekai.sfa.compose.screen.qrscan.QRCodeCropArea +import io.nekohasekai.sfa.update.UpdateInfo interface VendorInterface { - fun checkUpdateAvailable(): Boolean fun checkUpdate(activity: Activity, byUser: Boolean) + fun createQRCodeAnalyzer( onSuccess: (String) -> Unit, - onFailure: (Exception) -> Unit + onFailure: (Exception) -> Unit, + onCropArea: ((QRCodeCropArea?) -> Unit)? = null, ): ImageAnalysis.Analyzer? -} \ No newline at end of file + + /** + * Check if Per-app Proxy feature is available + * @return true if available, false if disabled (e.g., for Play Store builds) + */ + fun isPerAppProxyAvailable(): Boolean = true + + /** + * Check if track selection is available (e.g., stable/beta) + * @return true if track selection is supported + */ + fun supportsTrackSelection(): Boolean = false + + /** + * Check for updates asynchronously + * @return UpdateInfo if update is available, null otherwise + */ + fun checkUpdateAsync(): UpdateInfo? = null + + /** + * Check if silent install feature is available + * @return true if silent install is supported (Other flavor only) + */ + fun supportsSilentInstall(): Boolean = false + + /** + * Check if auto update feature is available + * @return true if auto update is supported (Other flavor only) + */ + fun supportsAutoUpdate(): Boolean = false + + /** + * Schedule auto update worker + */ + fun scheduleAutoUpdate() {} + + /** + * Verify if the specified silent install method is available + * @param method The install method (SHIZUKU or ROOT) + * @return true if the method is available and working + */ + suspend fun verifySilentInstallMethod(method: String): Boolean = false + + /** + * Download and install an APK update + * @param context The context + * @param downloadUrl The URL to download the APK from + * @throws Exception if download or install fails + */ + suspend fun downloadAndInstall(context: android.content.Context, downloadUrl: String): Unit = throw UnsupportedOperationException("Not supported in this flavor") +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt new file mode 100644 index 0000000000..2603ba9f6a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookErrorStore.kt @@ -0,0 +1,91 @@ +package io.nekohasekai.sfa.xposed + +import android.util.Log +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.bg.LogEntry +import java.util.ArrayDeque + +object HookErrorStore { + private const val MAX_ENTRIES = 100 + + private val lock = Any() + private val entries = ArrayDeque() + + fun i(source: String, message: String, throwable: Throwable? = null) { + log(LogEntry.LEVEL_INFO, source, message, throwable, store = true) + } + + fun w(source: String, message: String, throwable: Throwable? = null) { + log(LogEntry.LEVEL_WARN, source, message, throwable, store = true) + } + + fun e(source: String, message: String, throwable: Throwable? = null) { + log(LogEntry.LEVEL_ERROR, source, message, throwable, store = true) + } + + fun d(source: String, message: String, throwable: Throwable? = null) { + log(LogEntry.LEVEL_DEBUG, source, message, throwable, store = false) + } + + private fun log(level: Int, source: String, message: String, throwable: Throwable?, store: Boolean) { + if (BuildConfig.DEBUG) { + when (level) { + LogEntry.LEVEL_DEBUG -> { + if (throwable != null) { + Log.d(XposedInit.TAG, "[$source] $message", throwable) + } else { + Log.d(XposedInit.TAG, "[$source] $message") + } + } + LogEntry.LEVEL_INFO -> { + if (throwable != null) { + Log.i(XposedInit.TAG, "[$source] $message", throwable) + } else { + Log.i(XposedInit.TAG, "[$source] $message") + } + } + LogEntry.LEVEL_WARN -> { + if (throwable != null) { + Log.w(XposedInit.TAG, "[$source] $message", throwable) + } else { + Log.w(XposedInit.TAG, "[$source] $message") + } + } + LogEntry.LEVEL_ERROR -> { + if (throwable != null) { + Log.e(XposedInit.TAG, "[$source] $message", throwable) + } else { + Log.e(XposedInit.TAG, "[$source] $message") + } + } + } + } + if (!store || level == LogEntry.LEVEL_DEBUG) return + val stackTrace = throwable?.let { Log.getStackTraceString(it) } + val entry = LogEntry(level, System.currentTimeMillis(), source, message, stackTrace) + synchronized(lock) { + entries.addLast(entry) + while (entries.size > MAX_ENTRIES) { + entries.removeFirst() + } + } + } + + fun snapshot(): List { + synchronized(lock) { + return entries.toList() + } + } + + fun hasWarnings(): Boolean { + synchronized(lock) { + return entries.any { it.level >= LogEntry.LEVEL_WARN } + } + } + + fun clear() { + synchronized(lock) { + entries.clear() + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt new file mode 100644 index 0000000000..fa29f11808 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookModuleVersion.kt @@ -0,0 +1,5 @@ +package io.nekohasekai.sfa.xposed + +object HookModuleVersion { + const val CURRENT = 3 +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt new file mode 100644 index 0000000000..f8ee3cd5a5 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusKeys.kt @@ -0,0 +1,10 @@ +package io.nekohasekai.sfa.xposed + +object HookStatusKeys { + const val DESCRIPTOR = "android.net.IConnectivityManager" + const val TRANSACTION_STATUS = 0x5F00 + const val TRANSACTION_UPDATE_PRIVILEGE_SETTINGS = 0x5F01 + const val TRANSACTION_GET_ERRORS = 0x5F02 + const val TRANSACTION_EXPORT_DEBUG_INFO = 0x5F03 + const val TRANSACTION_GET_INSTALLED_PACKAGES = 0x5F04 +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt new file mode 100644 index 0000000000..17d9d60e52 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/HookStatusStore.kt @@ -0,0 +1,23 @@ +package io.nekohasekai.sfa.xposed + +import android.os.Process + +object HookStatusStore { + @Volatile + private var active = false + + @Volatile + private var lastPatchedAt = 0L + + fun markHookActive() { + active = true + } + + fun markPatched() { + lastPatchedAt = System.currentTimeMillis() + } + + fun snapshot(): Status = Status(active, lastPatchedAt, HookModuleVersion.CURRENT, Process.myPid()) + + data class Status(val active: Boolean, val lastPatchedAt: Long, val version: Int, val systemPid: Int) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt new file mode 100644 index 0000000000..cbd7514aac --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeChecker.kt @@ -0,0 +1,146 @@ +package io.nekohasekai.sfa.xposed + +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import android.os.Process +import java.lang.reflect.Method +import java.util.concurrent.ConcurrentHashMap + +object PrivilegeChecker { + private const val PER_USER_RANGE = 100000 + private val privilegedPermissions = arrayOf( + "android.permission.NETWORK_STACK", + "android.permission.MAINLINE_NETWORK_STACK", + "android.permission.NETWORK_SETTINGS", + "android.permission.CONNECTIVITY_INTERNAL", + "android.permission.CONTROL_VPN", + "android.permission.CONTROL_ALWAYS_ON_VPN", + ) + private val exemptPackages = emptySet() + private val exemptCache = ConcurrentHashMap() + private val privilegedCache = ConcurrentHashMap() + + private val appGlobalsClass by lazy { Class.forName("android.app.AppGlobals") } + private val getPackageManagerMethod by lazy { appGlobalsClass.getMethod("getPackageManager") } + private var getPackagesForUidMethod: Method? = null + private var checkUidPermissionMethod: Method? = null + private var getApplicationInfoMethodLong: Method? = null + private var getApplicationInfoMethodInt: Method? = null + + fun isPrivilegedUid(uid: Int): Boolean { + if (uid < Process.FIRST_APPLICATION_UID) { + return true + } + val cached = privilegedCache[uid] + if (cached != null) { + return cached + } + if (isExemptUid(uid)) { + privilegedCache[uid] = true + return true + } + val packages = getPackagesForUid(uid) + val pm = getPackageManager() + if (pm != null && packages.isNotEmpty()) { + val userId = uid / PER_USER_RANGE + for (pkg in packages) { + val appInfo = getApplicationInfo(pm, pkg, userId) + if (appInfo != null && isSystemApp(appInfo)) { + privilegedCache[uid] = true + return true + } + } + val checkMethod = checkUidPermissionMethod ?: run { + pm.javaClass.getMethod( + "checkUidPermission", + String::class.java, + Int::class.javaPrimitiveType, + ).also { checkUidPermissionMethod = it } + } + for (permission in privilegedPermissions) { + val result = try { + checkMethod.invoke(pm, permission, uid) as? Int + } catch (_: Throwable) { + null + } + if (result == PackageManager.PERMISSION_GRANTED) { + privilegedCache[uid] = true + return true + } + } + } + privilegedCache[uid] = false + return false + } + + private fun isSystemApp(appInfo: ApplicationInfo): Boolean { + val flags = appInfo.flags + return flags and ApplicationInfo.FLAG_SYSTEM != 0 || + flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 + } + + private fun isExemptUid(uid: Int): Boolean { + if (exemptPackages.isEmpty()) { + return false + } + val cached = exemptCache[uid] + if (cached != null) { + return cached + } + val packages = getPackagesForUid(uid) + val isExempt = packages.any { it in exemptPackages } + exemptCache[uid] = isExempt + return isExempt + } + + private fun getPackagesForUid(uid: Int): List { + val pm = getPackageManager() ?: return emptyList() + return try { + val method = getPackagesForUidMethod ?: run { + pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType).also { + getPackagesForUidMethod = it + } + } + val result = method.invoke(pm, uid) + when (result) { + is Array<*> -> result.filterIsInstance() + is List<*> -> result.filterIsInstance() + else -> emptyList() + } + } catch (_: Throwable) { + emptyList() + } + } + + private fun getPackageManager(): Any? = try { + getPackageManagerMethod.invoke(null) + } catch (_: Throwable) { + null + } + + private fun getApplicationInfo(pm: Any, pkg: String, userId: Int): ApplicationInfo? = try { + val method = getApplicationInfoMethodInt ?: run { + pm.javaClass.getMethod( + "getApplicationInfo", + String::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ).also { getApplicationInfoMethodInt = it } + } + method.invoke(pm, pkg, 0, userId) as? ApplicationInfo + } catch (_: Throwable) { + try { + val method = getApplicationInfoMethodLong ?: run { + pm.javaClass.getMethod( + "getApplicationInfo", + String::class.java, + Long::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ).also { getApplicationInfoMethodLong = it } + } + method.invoke(pm, pkg, 0L, userId) as? ApplicationInfo + } catch (_: Throwable) { + null + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt new file mode 100644 index 0000000000..6ac928ae0f --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/PrivilegeSettingsStore.kt @@ -0,0 +1,137 @@ +package io.nekohasekai.sfa.xposed + +import java.io.File +import java.lang.reflect.Method +import java.util.concurrent.ConcurrentHashMap + +object PrivilegeSettingsStore { + private const val SETTINGS_DIR = "/data/system/sing-box" + private const val SETTINGS_FILE = "privilege_settings.conf" + + @Volatile + private var enabled = false + + @Volatile + private var packageSet: Set = emptySet() + + @Volatile + private var interfaceRenameEnabled = false + + @Volatile + private var interfacePrefix = "en" + private val uidCache = ConcurrentHashMap() + + private val appGlobalsClass by lazy { Class.forName("android.app.AppGlobals") } + private val getPackageManagerMethod by lazy { appGlobalsClass.getMethod("getPackageManager") } + private var getPackagesForUidMethod: Method? = null + + fun update(enabled: Boolean, packages: Set, interfaceRenameEnabled: Boolean, interfacePrefix: String) { + this.enabled = enabled + packageSet = packages + this.interfaceRenameEnabled = interfaceRenameEnabled + this.interfacePrefix = normalizePrefix(interfacePrefix) + uidCache.clear() + HookErrorStore.i( + "PrivilegeSettingsStore", + "PrivilegeSettings updated: enabled=$enabled size=${packages.size} rename=$interfaceRenameEnabled prefix=${this.interfacePrefix}", + ) + writeSettingsFile() + } + + fun isEnabled(): Boolean = enabled + + fun shouldRenameInterface(): Boolean = interfaceRenameEnabled + + fun interfacePrefix(): String = interfacePrefix + + fun isUidSelected(uid: Int): Boolean { + val cached = uidCache[uid] + if (cached != null) { + return cached + } + val selected = getPackagesForUid(uid).any { packageSet.contains(it) } + uidCache[uid] = selected + return selected + } + + fun shouldHideUid(uid: Int): Boolean { + if (!enabled) { + return false + } + return isUidSelected(uid) + } + + private fun normalizePrefix(prefix: String): String { + val trimmed = prefix.trim() + if (trimmed.isEmpty()) { + return "en" + } + val filtered = buildString(trimmed.length) { + for (ch in trimmed) { + if (ch.isLetterOrDigit() || ch == '_') { + append(ch) + } + } + } + return if (filtered.isEmpty()) "en" else filtered + } + + private fun writeSettingsFile() { + try { + val dir = File(SETTINGS_DIR) + if (!dir.exists() && !dir.mkdirs()) { + HookErrorStore.e("PrivilegeSettingsStore", "Failed to create settings dir: ${dir.path}") + return + } + val file = File(dir, SETTINGS_FILE) + val packagesLine = packageSet.sorted().joinToString(",") + val content = buildString { + append("version=1\n") + append("enabled=") + append(if (enabled) "1" else "0") + append('\n') + append("rename=") + append(if (interfaceRenameEnabled) "1" else "0") + append('\n') + append("prefix=") + append(interfacePrefix) + append('\n') + append("packages=") + append(packagesLine) + append('\n') + } + file.writeText(content) + file.setReadable(true, true) + file.setWritable(true, true) + } catch (e: Throwable) { + HookErrorStore.e("PrivilegeSettingsStore", "Failed to write privilege settings file", e) + } + } + + private fun getPackagesForUid(uid: Int): List { + val pm = getPackageManager() ?: return emptyList() + return try { + val method = getPackagesForUidMethod ?: run { + pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType).also { + getPackagesForUidMethod = it + } + } + val result = method.invoke(pm, uid) + when (result) { + is Array<*> -> result.filterIsInstance() + is List<*> -> result.filterIsInstance() + else -> emptyList() + } + } catch (e: Throwable) { + HookErrorStore.e("PrivilegeSettingsStore", "getPackagesForUid failed for uid=$uid", e) + emptyList() + } + } + + private fun getPackageManager(): Any? = try { + getPackageManagerMethod.invoke(null) + } catch (e: Throwable) { + HookErrorStore.e("PrivilegeSettingsStore", "getPackageManager failed", e) + null + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt new file mode 100644 index 0000000000..f13ded4ab3 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnAppStore.kt @@ -0,0 +1,188 @@ +package io.nekohasekai.sfa.xposed + +import android.Manifest +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Binder +import android.os.SystemClock +import io.nekohasekai.sfa.BuildConfig +import java.lang.reflect.Method +import java.util.concurrent.ConcurrentHashMap + +object VpnAppStore { + private const val PER_USER_RANGE = 100000 + private const val REFRESH_INTERVAL_MS = 60_000L + private const val UID_CACHE_MS = 5_000L + + private data class CacheEntry(val atMs: Long, val value: T) + + private val vpnPackagesByUser = ConcurrentHashMap>>() + private val uidVpnCache = ConcurrentHashMap>() + private val uidPackagesCache = ConcurrentHashMap>>() + + private val appGlobalsClass by lazy { Class.forName("android.app.AppGlobals") } + private val getPackageManagerMethod by lazy { appGlobalsClass.getMethod("getPackageManager") } + + @Volatile + private var pmClass: Class<*>? = null + private var getPackagesForUidMethod: Method? = null + private var getInstalledPackagesMethodLong: Method? = null + private var getInstalledPackagesMethodInt: Method? = null + private var getListMethod: Method? = null + + fun isVpnUid(uid: Int): Boolean { + val now = SystemClock.uptimeMillis() + val cached = uidVpnCache[uid] + if (cached != null && now - cached.atMs < UID_CACHE_MS) { + return cached.value + } + val callerPackages = getPackagesForUid(uid) + val userId = uid / PER_USER_RANGE + val vpnSet = getVpnPackages(userId) + val result = callerPackages.any { vpnSet.contains(it) } + uidVpnCache[uid] = CacheEntry(now, result) + return result + } + + fun isVpnPackage(packageName: String, userId: Int): Boolean = getVpnPackages(userId).contains(packageName) + + fun isVpnUidExcludeSelf(uid: Int): Boolean { + val packages = getPackagesForUid(uid) + if (packages.contains(BuildConfig.APPLICATION_ID)) { + return false + } + val userId = uid / PER_USER_RANGE + val vpnSet = getVpnPackages(userId) + return packages.any { vpnSet.contains(it) } + } + + fun getPackagesForUid(uid: Int): List { + val now = SystemClock.uptimeMillis() + val cached = uidPackagesCache[uid] + if (cached != null && now - cached.atMs < UID_CACHE_MS) { + return cached.value + } + val result = binderLocalScope { + val pm = getPackageManager() ?: return@binderLocalScope emptyList() + try { + val method = getPackagesForUidMethod ?: run { + pm.javaClass.getMethod("getPackagesForUid", Int::class.javaPrimitiveType).also { + getPackagesForUidMethod = it + } + } + when (val raw = method.invoke(pm, uid)) { + is Array<*> -> raw.filterIsInstance() + is List<*> -> raw.filterIsInstance() + else -> emptyList() + } + } catch (e: Throwable) { + HookErrorStore.e("VpnAppStore", "getPackagesForUid failed for uid=$uid", e) + emptyList() + } + } + uidPackagesCache[uid] = CacheEntry(now, result) + return result + } + + private fun getVpnPackages(userId: Int): Set { + val now = SystemClock.uptimeMillis() + val cached = vpnPackagesByUser[userId] + if (cached != null && now - cached.atMs < REFRESH_INTERVAL_MS) { + return cached.value + } + val refreshed = scanVpnPackages(userId) + vpnPackagesByUser[userId] = CacheEntry(now, refreshed) + uidVpnCache.clear() + return refreshed + } + + private fun scanVpnPackages(userId: Int): Set { + return binderLocalScope { + val pm = getPackageManager() ?: return@binderLocalScope emptySet() + val flags = PackageManager.MATCH_DISABLED_COMPONENTS or + PackageManager.MATCH_DIRECT_BOOT_AWARE or + PackageManager.MATCH_DIRECT_BOOT_UNAWARE or + PackageManager.GET_SERVICES + val packages = getInstalledPackagesCompat(pm, flags.toLong(), userId) + val result = HashSet() + for (pkg in packages) { + val appInfo = pkg.applicationInfo ?: continue + if (isSystemApp(appInfo)) continue + val services = pkg.services ?: continue + if (services.any { it.permission == Manifest.permission.BIND_VPN_SERVICE }) { + result.add(pkg.packageName) + } + } + HookErrorStore.d("VpnAppStore", "VPN apps refreshed user=$userId count=${result.size}") + result + } + } + + private fun getInstalledPackagesCompat(pm: Any, flags: Long, userId: Int): List { + val result = try { + val method = getInstalledPackagesMethodLong ?: run { + pm.javaClass.getMethod( + "getInstalledPackages", + Long::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ).also { getInstalledPackagesMethodLong = it } + } + method.invoke(pm, flags, userId) + } catch (_: Throwable) { + try { + val method = getInstalledPackagesMethodInt ?: run { + pm.javaClass.getMethod( + "getInstalledPackages", + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ).also { getInstalledPackagesMethodInt = it } + } + method.invoke(pm, flags.toInt(), userId) + } catch (e: Throwable) { + HookErrorStore.e("VpnAppStore", "getInstalledPackages failed", e) + return emptyList() + } + } + return unwrapParceledListSlice(result) + } + + private fun isSystemApp(info: ApplicationInfo): Boolean = info.flags and ApplicationInfo.FLAG_SYSTEM != 0 || + info.flags and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 + + private fun getPackageManager(): Any? = try { + getPackageManagerMethod.invoke(null) + } catch (e: Throwable) { + HookErrorStore.e("VpnAppStore", "getPackageManager failed", e) + null + } + + private inline fun binderLocalScope(block: () -> T): T { + val token = Binder.clearCallingIdentity() + return try { + block() + } finally { + Binder.restoreCallingIdentity(token) + } + } + + private fun unwrapParceledListSlice(raw: Any?): List { + if (raw == null) return emptyList() + if (raw is List<*>) { + return raw.filterIsInstance() + } + return try { + val method = getListMethod ?: run { + raw.javaClass.getMethod("getList").also { getListMethod = it } + } + val list = method.invoke(raw) + if (list is List<*>) { + list.filterIsInstance() + } else { + emptyList() + } + } catch (_: Throwable) { + emptyList() + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt new file mode 100644 index 0000000000..50ffa96189 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnHideContext.kt @@ -0,0 +1,19 @@ +package io.nekohasekai.sfa.xposed + +object VpnHideContext { + private val targetUid = ThreadLocal() + + fun setTargetUid(uid: Int) { + targetUid.set(uid) + } + + fun consumeTargetUid(): Int? { + val value = targetUid.get() + targetUid.remove() + return value + } + + fun clear() { + targetUid.remove() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt new file mode 100644 index 0000000000..1aa35f1da4 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/VpnSanitizer.kt @@ -0,0 +1,153 @@ +package io.nekohasekai.sfa.xposed + +import android.net.LinkProperties +import android.net.NetworkCapabilities +import android.net.NetworkInfo +import android.net.ProxyInfo +import android.os.Build +import android.os.Parcel +import android.os.Process +import de.robv.android.xposed.XposedHelpers +import java.util.Locale + +object VpnSanitizer { + private val vpnInterfacePrefixes = arrayOf( + "tun", + ) + + private val getStackedLinksMethod by lazy { + LinkProperties::class.java.getMethod("getStackedLinks") + } + private val removeStackedLinkMethod by lazy { + LinkProperties::class.java.getMethod("removeStackedLink", String::class.java) + } + private val setHttpProxyMethod by lazy { + LinkProperties::class.java.getMethod("setHttpProxy", ProxyInfo::class.java) + } + private val removeTransportTypeMethod by lazy { + NetworkCapabilities::class.java.getMethod("removeTransportType", Int::class.javaPrimitiveType) + } + private val addCapabilityMethod by lazy { + NetworkCapabilities::class.java.getMethod("addCapability", Int::class.javaPrimitiveType) + } + + fun shouldHide(uid: Int): Boolean { + if (!PrivilegeSettingsStore.shouldHideUid(uid)) { + return false + } + if (VpnAppStore.isVpnUidExcludeSelf(uid)) { + return false + } + return true + } + + fun sanitizeRequestCapabilities(source: NetworkCapabilities): NetworkCapabilities { + val caps = NetworkCapabilities(source) + sanitizeTransport(caps) + return caps + } + + fun sanitizeNetworkCapabilities(source: NetworkCapabilities): NetworkCapabilities { + val caps = NetworkCapabilities(source) + sanitizeTransport(caps) + clearUnderlyingNetworks(caps) + clearOwnerUid(caps) + clearVpnTransportInfo(caps) + return caps + } + + fun sanitizeLinkProperties(source: LinkProperties): LinkProperties { + val lp = cloneLinkProperties(source) + clearHttpProxy(lp) + val iface = lp.interfaceName + if (isVpnInterface(iface)) { + lp.setInterfaceName(null) + } + @Suppress("UNCHECKED_CAST") + val stacked = getStackedLinksMethod.invoke(lp) as? List + if (!stacked.isNullOrEmpty()) { + for (link in stacked) { + clearHttpProxy(link) + val iface = link.interfaceName + if (iface != null && isVpnInterface(iface)) { + removeStackedLinkMethod.invoke(lp, iface) + } + } + } + return lp + } + + fun hasVpnInterface(lp: LinkProperties): Boolean { + if (isVpnInterface(lp.interfaceName)) { + return true + } + @Suppress("UNCHECKED_CAST") + val stacked = getStackedLinksMethod.invoke(lp) as? List ?: return false + return stacked.any { isVpnInterface(it.interfaceName) } + } + + fun isVpnInterface(iface: String?): Boolean { + if (iface.isNullOrEmpty()) return false + val name = iface.lowercase(Locale.US) + return vpnInterfacePrefixes.any { name.startsWith(it) } + } + + private fun sanitizeTransport(caps: NetworkCapabilities) { + removeTransportTypeMethod.invoke(caps, NetworkCapabilities.TRANSPORT_VPN) + addCapabilityMethod.invoke(caps, NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + } + + private fun clearUnderlyingNetworks(caps: NetworkCapabilities) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mUnderlyingNetworks") + field.set(caps, null) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val field = XposedHelpers.findFieldIfExists(NetworkCapabilities::class.java, "mUnderlyingNetworks") + field?.set(caps, null) + } + } + + private fun clearOwnerUid(caps: NetworkCapabilities) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mOwnerUid") + field.setInt(caps, Process.INVALID_UID) + } + } + + private fun clearVpnTransportInfo(caps: NetworkCapabilities) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return + } + val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mTransportInfo") + val info = field.get(caps) ?: return + if (info.javaClass.name.contains("VpnTransportInfo")) { + field.set(caps, null) + } + } + + private fun clearHttpProxy(lp: LinkProperties) { + setHttpProxyMethod.invoke(lp, null as ProxyInfo?) + } + + fun cloneLinkProperties(source: LinkProperties): LinkProperties { + val parcel = Parcel.obtain() + return try { + source.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + LinkProperties.CREATOR.createFromParcel(parcel) + } finally { + parcel.recycle() + } + } + + fun cloneNetworkInfo(source: NetworkInfo): NetworkInfo { + val parcel = Parcel.obtain() + return try { + source.writeToParcel(parcel, 0) + parcel.setDataPosition(0) + NetworkInfo.CREATOR.createFromParcel(parcel) + } finally { + parcel.recycle() + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt new file mode 100644 index 0000000000..ab4a2f0cae --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedActivation.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sfa.xposed + +import android.content.Context +import android.os.Process + +object XposedActivation { + private const val PREFS_NAME = "xposed_activation" + private const val KEY_ACTIVATED_PID = "activated_pid" + private const val KEY_ACTIVATED_AT = "activated_at" + private const val KEY_SYSTEM_IN_SCOPE = "system_in_scope" + + fun markActivated(context: Context) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putInt(KEY_ACTIVATED_PID, Process.myPid()) + .putLong(KEY_ACTIVATED_AT, System.currentTimeMillis()) + .apply() + } + + fun updateScope(context: Context, scope: Collection) { + val hasSystemScope = scope.any { it == "system" || it == "android" } + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_SYSTEM_IN_SCOPE, hasSystemScope) + .putLong(KEY_ACTIVATED_AT, System.currentTimeMillis()) + .apply() + } + + fun isActivated(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + if (prefs.contains(KEY_SYSTEM_IN_SCOPE)) { + return prefs.getBoolean(KEY_SYSTEM_IN_SCOPE, false) + } + return prefs.getInt(KEY_ACTIVATED_PID, -1) == Process.myPid() + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt new file mode 100644 index 0000000000..10fe4a298e --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/XposedInit.kt @@ -0,0 +1,54 @@ +package io.nekohasekai.sfa.xposed + +import android.content.Context +import io.github.libxposed.api.XposedInterface +import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedModuleInterface +import io.nekohasekai.sfa.xposed.hooks.HookIConnectivityManagerOnTransact +import io.nekohasekai.sfa.xposed.hooks.hidevpn.ConnectivityServiceHookHelper +import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkCapabilitiesWriteToParcel +import io.nekohasekai.sfa.xposed.hooks.hidevpn.HookNetworkInterfaceGetName +import io.nekohasekai.sfa.xposed.hooks.hidevpnapp.HookPackageManagerGetInstalledPackages + +class XposedInit(base: XposedInterface, param: XposedModuleInterface.ModuleLoadedParam) : XposedModule(base, param) { + + private val activityThreadClass by lazy { Class.forName("android.app.ActivityThread") } + private val currentActivityThreadMethod by lazy { activityThreadClass.getMethod("currentActivityThread") } + private val getSystemContextMethod by lazy { activityThreadClass.getMethod("getSystemContext") } + + override fun onSystemServerLoaded(param: XposedModuleInterface.SystemServerLoadedParam) { + val systemContext = resolveSystemContext() + HookErrorStore.i("XposedInit", "handleSystemServerLoaded") + val hooks = arrayOf( + ConnectivityServiceHookHelper(param.classLoader), + HookIConnectivityManagerOnTransact(param.classLoader, systemContext), + HookPackageManagerGetInstalledPackages(param.classLoader), + HookNetworkCapabilitiesWriteToParcel(), + HookNetworkInterfaceGetName(param.classLoader), + ) + + hooks.forEach { hook -> + try { + hook.injectHook() + } catch (e: Throwable) { + HookErrorStore.e( + "XposedInit", + "Failed to inject ${hook.javaClass.simpleName}", + e, + ) + } + } + } + + companion object { + const val TAG = "sing-box-lsposed" + } + + private fun resolveSystemContext(): Context? = try { + val currentThread = currentActivityThreadMethod.invoke(null) + getSystemContextMethod.invoke(currentThread) as? Context + } catch (e: Throwable) { + HookErrorStore.e("XposedInit", "resolveSystemContext failed", e) + null + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt new file mode 100644 index 0000000000..707e7f7b82 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/IConnectivityManager+onTransact.kt @@ -0,0 +1,196 @@ +package io.nekohasekai.sfa.xposed.hooks + +import android.content.Context +import android.content.pm.PackageInfo +import android.os.Binder +import android.os.Parcel +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.bg.PackageEntry +import io.nekohasekai.sfa.bg.ParceledListSlice +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.HookStatusKeys +import io.nekohasekai.sfa.xposed.HookStatusStore +import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore + +class HookIConnectivityManagerOnTransact(private val classLoader: ClassLoader, private val context: Context?) : XHook { + private companion object { + private const val SOURCE = "HookIConnectivityManagerOnTransact" + } + + override fun injectHook() { + val stub = XposedHelpers.findClass("android.net.IConnectivityManager\$Stub", classLoader) + val descriptor = XposedHelpers.getStaticObjectField(stub, "DESCRIPTOR") as String + XposedHelpers.findAndHookMethod( + stub, + "onTransact", + Int::class.javaPrimitiveType, + Parcel::class.java, + Parcel::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val code = param.args[0] as Int + if (code != HookStatusKeys.TRANSACTION_STATUS && + code != HookStatusKeys.TRANSACTION_UPDATE_PRIVILEGE_SETTINGS && + code != HookStatusKeys.TRANSACTION_GET_ERRORS && + code != HookStatusKeys.TRANSACTION_GET_INSTALLED_PACKAGES + ) { + return + } + val data = param.args[1] as Parcel + val reply = param.args[2] as Parcel? + try { + data.enforceInterface(descriptor) + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "IConnectivityManager transact bad interface", e) + reply?.writeException(SecurityException("bad interface")) + param.result = true + return + } + if (!isCallerAllowed()) { + reply!!.writeException(SecurityException("unauthorized")) + param.result = true + return + } + if (code == HookStatusKeys.TRANSACTION_STATUS) { + val status = HookStatusStore.snapshot() + reply!!.writeNoException() + reply.writeInt(if (status.active) 1 else 0) + reply.writeLong(status.lastPatchedAt) + reply.writeInt(status.version) + reply.writeInt(status.systemPid) + param.result = true + return + } + if (code == HookStatusKeys.TRANSACTION_GET_ERRORS) { + val hasWarnings = HookErrorStore.hasWarnings() + val entries = HookErrorStore.snapshot() + reply!!.writeNoException() + reply.writeInt(if (hasWarnings) 1 else 0) + ParceledListSlice(entries).writeToParcel(reply, 0) + param.result = true + return + } + if (code == HookStatusKeys.TRANSACTION_GET_INSTALLED_PACKAGES) { + val flags = data.readLong() + val userId = data.readInt() + val packages = getInstalledPackages(flags, userId) + reply!!.writeNoException() + ParceledListSlice(packages).writeToParcel(reply, 0) + param.result = true + return + } + val enabled = data.readInt() != 0 + val slice = ParceledListSlice.CREATOR.createFromParcel(data, PackageEntry::class.java.classLoader) + val packages = HashSet() + for (entry in slice.list) { + if (entry is PackageEntry) { + packages.add(entry.packageName) + } + } + var renameEnabled = false + var prefix = "en" + if (data.dataAvail() >= 4) { + renameEnabled = data.readInt() != 0 + if (data.dataAvail() > 0) { + prefix = data.readString() ?: "en" + } + } + PrivilegeSettingsStore.update(enabled, packages, renameEnabled, prefix) + reply!!.writeNoException() + param.result = true + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked IConnectivityManager.onTransact") + } + + private fun isCallerAllowed(): Boolean { + val uid = Binder.getCallingUid() + if (uid == 0) return true + val pm = context?.packageManager + if (pm == null) { + HookErrorStore.e(SOURCE, "isCallerAllowed: context or packageManager is null, uid=$uid") + return false + } + return try { + val packages = pm.getPackagesForUid(uid) + if (packages == null) { + HookErrorStore.w(SOURCE, "isCallerAllowed: getPackagesForUid returned null for uid=$uid") + return false + } + packages.any { it == BuildConfig.APPLICATION_ID } + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "isCallerAllowed failed for uid=$uid", e) + false + } + } + + private fun getInstalledPackages(flags: Long, userId: Int): List { + return binderLocalScope { + val pm = getPackageManager() ?: return@binderLocalScope emptyList() + getInstalledPackagesCompat(pm, flags, userId) + } + } + + private inline fun binderLocalScope(block: () -> T): T { + val token = Binder.clearCallingIdentity() + return try { + block() + } finally { + Binder.restoreCallingIdentity(token) + } + } + + private fun getPackageManager(): Any? = try { + val appGlobals = Class.forName("android.app.AppGlobals") + val method = appGlobals.getMethod("getPackageManager") + method.invoke(null) + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "getPackageManager failed", e) + null + } + + private fun getInstalledPackagesCompat(pm: Any, flags: Long, userId: Int): List { + val result = try { + val method = pm.javaClass.getMethod( + "getInstalledPackages", + Long::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) + method.invoke(pm, flags, userId) + } catch (_: Throwable) { + try { + val method = pm.javaClass.getMethod( + "getInstalledPackages", + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + ) + method.invoke(pm, flags.toInt(), userId) + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "getInstalledPackages failed", e) + return emptyList() + } + } + return unwrapParceledListSlice(result) + } + + private fun unwrapParceledListSlice(raw: Any?): List { + if (raw == null) return emptyList() + if (raw is List<*>) { + return raw.filterIsInstance() + } + return try { + val method = raw.javaClass.getMethod("getList") + val list = method.invoke(raw) + if (list is List<*>) { + list.filterIsInstance() + } else { + emptyList() + } + } catch (_: Throwable) { + emptyList() + } + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt new file mode 100644 index 0000000000..a62533a6f1 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/SafeMethodHook.kt @@ -0,0 +1,32 @@ +package io.nekohasekai.sfa.xposed.hooks + +import de.robv.android.xposed.XC_MethodHook +import io.nekohasekai.sfa.xposed.HookErrorStore + +abstract class SafeMethodHook(private val source: String) : XC_MethodHook() { + @Volatile + private var disabled = false + + final override fun beforeHookedMethod(param: MethodHookParam) { + if (disabled) return + try { + beforeHook(param) + } catch (e: Throwable) { + disabled = true + HookErrorStore.e(source, "Hook disabled due to unrecoverable error", e) + } + } + + final override fun afterHookedMethod(param: MethodHookParam) { + if (disabled) return + try { + afterHook(param) + } catch (e: Throwable) { + disabled = true + HookErrorStore.e(source, "Hook disabled due to unrecoverable error", e) + } + } + + protected open fun beforeHook(param: MethodHookParam) {} + protected open fun afterHook(param: MethodHookParam) {} +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt new file mode 100644 index 0000000000..f5d7619d40 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/XHook.kt @@ -0,0 +1,5 @@ +package io.nekohasekai.sfa.xposed.hooks + +interface XHook { + fun injectHook() +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt new file mode 100644 index 0000000000..ebc825e1da --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+CONNECTIVITY_ACTION.kt @@ -0,0 +1,36 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.NetworkInfo +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerConnectivityAction(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerConnectivityAction" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "sendGeneralBroadcast", + NetworkInfo::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val info = param.args[0] as? NetworkInfo ?: return + if (info.type != ConnectivityManager.TYPE_VPN) return + val defaultNai = XposedHelpers.callMethod(param.thisObject, "getDefaultNetwork") + ?: return + if (helper.isVpnNai(defaultNai)) { + return + } + val replacement = XposedHelpers.getObjectField(defaultNai, "networkInfo") as? NetworkInfo + ?: return + param.args[0] = VpnSanitizer.cloneNetworkInfo(replacement) + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt new file mode 100644 index 0000000000..46339d2472 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+PROXY_CHANGE_ACTION.kt @@ -0,0 +1,83 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.content.Context +import android.content.Intent +import android.net.Proxy +import android.net.ProxyInfo +import android.os.Binder +import android.os.UserHandle +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerProxyChangeAction(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookProxyChangeAction" + } + + fun install() { + if (helper.sdkInt >= 29) { + try { + hookProxyBroadcastTracker() + return + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookProxyBroadcastTracker failed: ${e.message}", e) + } + } + + try { + hookLegacyProxyBroadcast() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookLegacyProxyBroadcast failed: ${e.message}", e) + } + } + + private fun hookProxyBroadcastTracker() { + val trackerClass = helper.resolveConnectivityModuleClass("ProxyTracker", "connectivity") + XposedHelpers.findAndHookMethod( + trackerClass, + "sendProxyBroadcast", + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val tracker = param.thisObject ?: return + val context = XposedHelpers.getObjectField(tracker, "mContext") as Context + val proxyInfo = emptyProxyInfo() + val intent = Intent(Proxy.PROXY_CHANGE_ACTION) + intent.addFlags(Intent.FLAG_RECEIVER_REPLACE_PENDING) + intent.putExtra("android.intent.extra.PROXY_INFO", proxyInfo) + val ident = Binder.clearCallingIdentity() + try { + val userAll = try { + UserHandle::class.java.getField("ALL").get(null) as? UserHandle + } catch (_: Throwable) { + null + } + if (userAll != null) { + context.sendStickyBroadcastAsUser(intent, userAll) + } else { + context.sendStickyBroadcast(intent) + } + } finally { + Binder.restoreCallingIdentity(ident) + } + param.result = null + } + }, + ) + } + + private fun hookLegacyProxyBroadcast() { + XposedHelpers.findAndHookMethod( + helper.cls, + "sendProxyBroadcast", + ProxyInfo::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + param.args[0] = emptyProxyInfo() + } + }, + ) + } + + private fun emptyProxyInfo(): ProxyInfo = ProxyInfo.buildDirectProxy("", 0) +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt new file mode 100644 index 0000000000..9ce9809402 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetwork.kt @@ -0,0 +1,41 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetActiveNetwork(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetActiveNetwork" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getActiveNetwork", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val replacement = helper.getUnderlyingNetwork(param.thisObject, uid) ?: return + param.result = replacement + } + }, + ) + + XposedHelpers.findAndHookMethod( + helper.cls, + "getActiveNetworkForUid", + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = param.args[0] as Int + if (!helper.shouldHide(param.thisObject, uid)) return + val replacement = helper.getUnderlyingNetwork(param.thisObject, uid) ?: return + param.result = replacement + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt new file mode 100644 index 0000000000..165337bc37 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getActiveNetworkInfo.kt @@ -0,0 +1,51 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.NetworkInfo +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetActiveNetworkInfo(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetActiveNetworkInfo" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getActiveNetworkInfo", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val info = param.result as? NetworkInfo ?: return + if (info.type != ConnectivityManager.TYPE_VPN) return + val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) + if (replacement != null) { + param.result = replacement + } + } + }, + ) + + XposedHelpers.findAndHookMethod( + helper.cls, + "getActiveNetworkInfoForUid", + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = param.args[0] as Int + if (!helper.shouldHide(param.thisObject, uid)) return + val info = param.result as? NetworkInfo ?: return + if (info.type != ConnectivityManager.TYPE_VPN) return + val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) + if (replacement != null) { + param.result = replacement + } + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt new file mode 100644 index 0000000000..8417399ebf --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworkInfo.kt @@ -0,0 +1,30 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.NetworkInfo +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetAllNetworkInfo(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetAllNetworkInfo" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getAllNetworkInfo", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + @Suppress("UNCHECKED_CAST") + val infos = param.result as? Array ?: return + val filtered = infos.filter { it.type != ConnectivityManager.TYPE_VPN } + param.result = filtered.toTypedArray() + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt new file mode 100644 index 0000000000..5d3af0d2fc --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getAllNetworks.kt @@ -0,0 +1,29 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.Network +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetAllNetworks(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetAllNetworks" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getAllNetworks", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + @Suppress("UNCHECKED_CAST") + val networks = param.result as? Array ?: return + val filtered = networks.filter { !helper.isVpnNetwork(param.thisObject, it) } + param.result = filtered.toTypedArray() + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt new file mode 100644 index 0000000000..acade8fa25 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getDefaultProxy.kt @@ -0,0 +1,43 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.Network +import android.net.ProxyInfo +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetDefaultProxy(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetDefaultProxy" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getProxyForNetwork", + Network::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + param.result as? ProxyInfo ?: return + param.result = null + } + }, + ) + + XposedHelpers.findAndHookMethod( + helper.cls, + "getGlobalProxy", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + param.result as? ProxyInfo ?: return + param.result = null + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt new file mode 100644 index 0000000000..a2e9d413c0 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getLinkProperties.kt @@ -0,0 +1,94 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetLinkProperties(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookGetLinkProperties" + } + + fun install() { + if (helper.sdkInt >= 30) { + try { + hookLinkPropertiesRestricted() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookLinkPropertiesRestricted failed: ${e.message}", e) + } + } + + try { + hookGetLinkProperties() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookGetLinkProperties failed: ${e.message}", e) + } + + try { + hookGetLinkPropertiesForType() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookGetLinkPropertiesForType failed: ${e.message}", e) + } + } + + private fun hookLinkPropertiesRestricted() { + XposedHelpers.findAndHookMethod( + helper.cls, + "linkPropertiesRestrictedForCallerPermissions", + LinkProperties::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val callerUid = param.args[2] as Int + val lp = param.result as? LinkProperties ?: return + if (!VpnSanitizer.hasVpnInterface(lp)) return + if (!VpnSanitizer.shouldHide(callerUid)) return + val underlying = helper.getUnderlyingLinkProperties(param.thisObject, callerUid) + param.result = underlying ?: VpnSanitizer.sanitizeLinkProperties(lp) + } + }, + ) + } + + private fun hookGetLinkProperties() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getLinkProperties", + Network::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val lp = param.result as? LinkProperties ?: return + if (!VpnSanitizer.hasVpnInterface(lp)) return + val underlying = helper.getUnderlyingLinkProperties(param.thisObject, uid) + param.result = underlying ?: VpnSanitizer.sanitizeLinkProperties(lp) + } + }, + ) + } + + private fun hookGetLinkPropertiesForType() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getLinkPropertiesForType", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val networkType = param.args[0] as Int + if (networkType == ConnectivityManager.TYPE_VPN) { + param.result = null + } + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt new file mode 100644 index 0000000000..9e7a539500 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkCapabilities.kt @@ -0,0 +1,161 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetNetworkCapabilities(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookGetNetworkCapabilities" + } + + fun install() { + // Hook networkCapabilitiesRestrictedForCallerPermissions (API 28+) + if (helper.sdkInt >= 28) { + try { + hookNetworkCapabilitiesRestricted() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookNetworkCapabilitiesRestricted failed: ${e.message}", e) + } + } + + // Hook getNetworkCapabilities based on API level + when { + helper.sdkInt >= 31 -> { + try { + hookGetNetworkCapabilitiesV12() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookGetNetworkCapabilitiesV12 failed: ${e.message}", e) + try { + hookGetNetworkCapabilitiesV11() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV11 failed: ${e2.message}", e2) + } + } + } + helper.sdkInt >= 30 -> { + try { + hookGetNetworkCapabilitiesV11() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookGetNetworkCapabilitiesV11 failed: ${e.message}", e) + try { + hookGetNetworkCapabilitiesV8() + } catch (e2: Throwable) { + HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV8 failed: ${e2.message}", e2) + } + } + } + else -> { + try { + hookGetNetworkCapabilitiesV8() + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "hookGetNetworkCapabilitiesV8 failed: ${e.message}", e) + } + } + } + + // Hook createWithLocationInfoSanitizedIfNecessaryWhenParceled (API 31+) + if (helper.sdkInt >= 31) { + try { + hookCreateWithLocationInfoSanitized() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCreateWithLocationInfoSanitized failed: ${e.message}", e) + } + } + } + + private fun hookNetworkCapabilitiesRestricted() { + XposedHelpers.findAndHookMethod( + helper.cls, + "networkCapabilitiesRestrictedForCallerPermissions", + NetworkCapabilities::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val callerUid = param.args[2] as Int + val nc = param.result as? NetworkCapabilities ?: return + if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + if (!VpnSanitizer.shouldHide(callerUid)) return + param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) + } + }, + ) + } + + private fun hookGetNetworkCapabilitiesV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkCapabilities", + Network::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + sanitizeNetworkCapabilitiesResult(param) + } + }, + ) + } + + private fun hookGetNetworkCapabilitiesV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkCapabilities", + Network::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + sanitizeNetworkCapabilitiesResult(param) + } + }, + ) + } + + private fun hookGetNetworkCapabilitiesV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkCapabilities", + Network::class.java, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + sanitizeNetworkCapabilitiesResult(param) + } + }, + ) + } + + private fun sanitizeNetworkCapabilitiesResult(param: de.robv.android.xposed.XC_MethodHook.MethodHookParam) { + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + val nc = param.result as? NetworkCapabilities ?: return + if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) + } + + private fun hookCreateWithLocationInfoSanitized() { + XposedHelpers.findAndHookMethod( + helper.cls, + "createWithLocationInfoSanitizedIfNecessaryWhenParceled", + NetworkCapabilities::class.java, + Boolean::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val callerUid = param.args[3] as Int + if (!helper.shouldHide(param.thisObject, callerUid)) return + val nc = param.result as? NetworkCapabilities ?: return + if (!nc.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return + param.result = VpnSanitizer.sanitizeNetworkCapabilities(nc) + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt new file mode 100644 index 0000000000..51d215113b --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkForType.kt @@ -0,0 +1,29 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetNetworkForType(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetNetworkForType" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkForType", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val type = param.args[0] as Int + if (type != ConnectivityManager.TYPE_VPN) return + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + param.result = null + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt new file mode 100644 index 0000000000..c3b2aad338 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+getNetworkInfo.kt @@ -0,0 +1,54 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkInfo +import android.os.Binder +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerGetNetworkInfo(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookConnectivityManagerGetNetworkInfo" + } + + fun install() { + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkInfo", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val type = param.args[0] as Int + if (type != ConnectivityManager.TYPE_VPN) return + val uid = Binder.getCallingUid() + if (!helper.shouldHide(param.thisObject, uid)) return + param.result = null + } + }, + ) + + XposedHelpers.findAndHookMethod( + helper.cls, + "getNetworkInfoForUid", + Network::class.java, + Int::class.javaPrimitiveType, + Boolean::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = param.args[1] as Int + if (!helper.shouldHide(param.thisObject, uid)) return + val info = param.result as? NetworkInfo ?: return + if (info.type != ConnectivityManager.TYPE_VPN) return + val replacement = helper.getUnderlyingNetworkInfo(param.thisObject, uid) + param.result = if (replacement != null) { + VpnSanitizer.cloneNetworkInfo(replacement) + } else { + null + } + } + }, + ) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt new file mode 100644 index 0000000000..f8001714d6 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityManager+requestNetwork.kt @@ -0,0 +1,574 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.NetworkCapabilities +import android.os.Binder +import android.os.Bundle +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.VpnHideContext +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook + +class HookConnectivityManagerRequestNetwork(private val helper: ConnectivityServiceHookHelper) { + private companion object { + private const val SOURCE = "HookRequestNetwork" + } + + fun install() { + // Hook requestNetwork based on API level + hookRequestNetwork() + + // Hook listenForNetwork based on API level + hookListenForNetwork() + + // Hook pendingRequestForNetwork + hookPendingRequestForNetwork() + + // Hook pendingListenForNetwork + hookPendingListenForNetwork() + + // Hook createDefaultNetworkCapabilitiesForUid (API 28+) + if (helper.sdkInt >= 28) { + try { + hookCreateDefaultNetworkCapabilities() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCreateDefaultNetworkCapabilities failed: ${e.message}", e) + } + } + + // Hook copyDefaultNetworkCapabilitiesForUid (API 31+) + if (helper.sdkInt >= 31) { + try { + hookCopyDefaultNetworkCapabilities() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCopyDefaultNetworkCapabilities failed: ${e.message}", e) + } + } + + // Hook callCallbackForRequest + hookCallCallbackForRequest() + + // Hook sendPendingIntentForRequest + try { + hookSendPendingIntentForRequest() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookSendPendingIntentForRequest failed: ${e.message}", e) + } + } + + private fun hookRequestNetwork() { + val methods = listOf( + "V16" to { hookRequestNetworkV16() }, + "V12" to { hookRequestNetworkV12() }, + "V11" to { hookRequestNetworkV11() }, + "V8" to { hookRequestNetworkV8() }, + ) + for ((version, hook) in methods) { + try { + hook() + return + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookRequestNetwork$version failed: ${e.message}", e) + } + } + HookErrorStore.e(SOURCE, "All hookRequestNetwork variants failed") + } + + private fun hookListenForNetwork() { + val methods = listOf( + "V16" to { hookListenForNetworkV16() }, + "V12" to { hookListenForNetworkV12() }, + "V11" to { hookListenForNetworkV11() }, + "V8" to { hookListenForNetworkV8() }, + ) + for ((version, hook) in methods) { + try { + hook() + return + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookListenForNetwork$version failed: ${e.message}", e) + } + } + HookErrorStore.e(SOURCE, "All hookListenForNetwork variants failed") + } + + private fun hookPendingRequestForNetwork() { + val methods = listOf( + "V12" to { hookPendingRequestForNetworkV12() }, + "V11" to { hookPendingRequestForNetworkV11() }, + "V8" to { hookPendingRequestForNetworkV8() }, + ) + for ((version, hook) in methods) { + try { + hook() + return + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookPendingRequestForNetwork$version failed: ${e.message}", e) + } + } + HookErrorStore.e(SOURCE, "All hookPendingRequestForNetwork variants failed") + } + + private fun hookPendingListenForNetwork() { + val methods = listOf( + "V12" to { hookPendingListenForNetworkV12() }, + "V11" to { hookPendingListenForNetworkV11() }, + "V8" to { hookPendingListenForNetworkV8() }, + ) + for ((version, hook) in methods) { + try { + hook() + return + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookPendingListenForNetwork$version failed: ${e.message}", e) + } + } + HookErrorStore.e(SOURCE, "All hookPendingListenForNetwork variants failed") + } + + // region requestNetwork versions + + private fun hookRequestNetworkV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "requestNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + Int::class.javaPrimitiveType, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookRequestNetworkV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "requestNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + Int::class.javaPrimitiveType, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookRequestNetworkV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "requestNetwork", + Int::class.javaPrimitiveType, + NetworkCapabilities::class.java, + Int::class.javaPrimitiveType, + android.os.Messenger::class.java, + Int::class.javaPrimitiveType, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[1] as? NetworkCapabilities ?: return + param.args[1] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookRequestNetworkV16() { + XposedHelpers.findAndHookMethod( + helper.cls, + "requestNetwork", + Int::class.javaPrimitiveType, + NetworkCapabilities::class.java, + Int::class.javaPrimitiveType, + android.os.Messenger::class.java, + Int::class.javaPrimitiveType, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[1] as? NetworkCapabilities ?: return + param.args[1] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + // endregion + + // region listenForNetwork versions + + private fun hookListenForNetworkV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "listenForNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + android.os.IBinder::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookListenForNetworkV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "listenForNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + android.os.IBinder::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookListenForNetworkV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "listenForNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookListenForNetworkV16() { + XposedHelpers.findAndHookMethod( + helper.cls, + "listenForNetwork", + NetworkCapabilities::class.java, + android.os.Messenger::class.java, + android.os.IBinder::class.java, + Int::class.javaPrimitiveType, + String::class.java, + String::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + // endregion + + // region pendingRequestForNetwork versions + + private fun hookPendingRequestForNetworkV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingRequestForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookPendingRequestForNetworkV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingRequestForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookPendingRequestForNetworkV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingRequestForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + // endregion + + // region pendingListenForNetwork versions + + private fun hookPendingListenForNetworkV8() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingListenForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookPendingListenForNetworkV11() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingListenForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookPendingListenForNetworkV12() { + XposedHelpers.findAndHookMethod( + helper.cls, + "pendingListenForNetwork", + NetworkCapabilities::class.java, + android.app.PendingIntent::class.java, + String::class.java, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val uid = Binder.getCallingUid() + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.args[0] as? NetworkCapabilities ?: return + param.args[0] = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + // endregion + + // region default capabilities + + private fun hookCreateDefaultNetworkCapabilities() { + XposedHelpers.findAndHookMethod( + helper.cls, + "createDefaultNetworkCapabilitiesForUid", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val uid = param.args[0] as? Int ?: return + if (!VpnSanitizer.shouldHide(uid)) return + val nc = param.result as? NetworkCapabilities ?: return + param.result = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + private fun hookCopyDefaultNetworkCapabilities() { + XposedHelpers.findAndHookMethod( + helper.cls, + "copyDefaultNetworkCapabilitiesForUid", + NetworkCapabilities::class.java, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + String::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val requestorUid = param.args[2] as? Int ?: return + if (!VpnSanitizer.shouldHide(requestorUid)) return + val nc = param.result as? NetworkCapabilities ?: return + param.result = VpnSanitizer.sanitizeRequestCapabilities(nc) + } + }, + ) + } + + // endregion + + // region callback hooks + + private fun hookCallCallbackForRequest() { + if (helper.sdkInt >= 36) { + // API 36+ has both WithAgent and WithBundle variants + try { + hookCallCallbackForRequestWithAgent() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithAgent failed: ${e.message}", e) + } + try { + hookCallCallbackForRequestWithBundle() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithBundle failed: ${e.message}", e) + } + } else { + try { + hookCallCallbackForRequestWithAgent() + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "hookCallCallbackForRequestWithAgent failed: ${e.message}", e) + } + } + } + + private fun hookCallCallbackForRequestWithAgent() { + val (nriClass, naiClass) = helper.resolveNriAndNaiClasses() + XposedHelpers.findAndHookMethod( + helper.cls, + "callCallbackForRequest", + nriClass, + naiClass, + Int::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val nri = param.args[0] ?: return + val uid = helper.getAsUid(nri) + if (!VpnSanitizer.shouldHide(uid)) return + val networkAgent = param.args[1] + if (networkAgent != null && helper.isVpnNai(networkAgent)) { + val underlying = helper.getUnderlyingNai(param.thisObject, uid) + if (underlying != null) { + param.args[1] = underlying + } + } + VpnHideContext.setTargetUid(uid) + } + + override fun afterHook(param: MethodHookParam) { + VpnHideContext.clear() + } + }, + ) + } + + private fun hookCallCallbackForRequestWithBundle() { + val (nriClass, _) = helper.resolveNriAndNaiClasses() + XposedHelpers.findAndHookMethod( + helper.cls, + "callCallbackForRequest", + nriClass, + Int::class.javaPrimitiveType, + Bundle::class.java, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val nri = param.args[0] ?: return + val uid = helper.getAsUid(nri) + if (!VpnSanitizer.shouldHide(uid)) return + VpnHideContext.setTargetUid(uid) + } + + override fun afterHook(param: MethodHookParam) { + VpnHideContext.clear() + } + }, + ) + } + + private fun hookSendPendingIntentForRequest() { + val (nriClass, naiClass) = helper.resolveNriAndNaiClasses() + XposedHelpers.findAndHookMethod( + helper.cls, + "sendPendingIntentForRequest", + nriClass, + naiClass, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val nri = param.args[0] ?: return + val uid = helper.getAsUid(nri) + if (!VpnSanitizer.shouldHide(uid)) return + val networkAgent = param.args[1] + if (networkAgent != null && helper.isVpnNai(networkAgent)) { + val underlying = helper.getUnderlyingNai(param.thisObject, uid) + if (underlying != null) { + param.args[1] = underlying + } + } + VpnHideContext.setTargetUid(uid) + } + + override fun afterHook(param: MethodHookParam) { + VpnHideContext.clear() + } + }, + ) + } + + // endregion +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt new file mode 100644 index 0000000000..39b0b3b85b --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/ConnectivityServiceHookHelper.kt @@ -0,0 +1,530 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.content.Context +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkInfo +import android.os.Build +import android.os.IBinder +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore +import io.nekohasekai.sfa.xposed.VpnAppStore +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook +import io.nekohasekai.sfa.xposed.hooks.XHook +import java.lang.reflect.Method +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean + +class ConnectivityServiceHookHelper(private val classLoader: ClassLoader) : XHook { + companion object { + private const val SOURCE = "ConnectivityServiceHookHelper" + } + + private val hooked = AtomicBoolean(false) + private val initializerHooked = AtomicBoolean(false) + private var classLoadUnhook: XC_MethodHook.Unhook? = null + private val serviceManagerHooked = AtomicBoolean(false) + private var connectivityClassLoader: ClassLoader = classLoader + private val skipLogKeys = ConcurrentHashMap() + val sdkInt = Build.VERSION.SDK_INT + + lateinit var cls: Class<*> + private set + + private val serviceManagerClass by lazy { Class.forName("android.os.ServiceManager") } + private val checkServiceMethod by lazy { serviceManagerClass.getMethod("checkService", String::class.java) } + + private var getVpnForUidMethod: Method? = null + private lateinit var getVpnUnderlyingNetworksMethod: Method + private lateinit var getNetworkAgentInfoForNetworkMethod: Method + private var getFilteredNetworkInfoMethod: Method? = null + private lateinit var getDefaultNetworkMethod: Method + private lateinit var isVPNMethod: Method + private var networkMethod: Method? = null + + override fun injectHook() { + val foundClass = findConnectivityServiceClass() + if (foundClass != null) { + installHooks(foundClass, "direct") + return + } + hookConnectivityServiceInitializer() + hookClassLoaderFallback() + tryHookFromServiceManager() + } + + private fun installHooks(cls: Class<*>, source: String) { + if (!hooked.compareAndSet(false, true)) { + return + } + this.cls = cls + connectivityClassLoader = cls.classLoader ?: classLoader + initMethodCache() + HookErrorStore.i( + SOURCE, + "Installing ConnectivityService hooks ($source) cls=${cls.name} loader=${connectivityClassLoader.javaClass.name}", + ) + + // Install all individual hooks + HookConnectivityManagerGetActiveNetwork(this).install() + HookConnectivityManagerGetActiveNetworkInfo(this).install() + HookConnectivityManagerGetNetworkInfo(this).install() + HookConnectivityManagerGetAllNetworkInfo(this).install() + HookConnectivityManagerGetAllNetworks(this).install() + HookConnectivityManagerGetNetworkForType(this).install() + HookConnectivityManagerGetNetworkCapabilities(this).install() + HookConnectivityManagerGetLinkProperties(this).install() + HookConnectivityManagerRequestNetwork(this).install() + HookConnectivityManagerGetDefaultProxy(this).install() + HookConnectivityManagerConnectivityAction(this).install() + HookConnectivityManagerProxyChangeAction(this).install() + + HookErrorStore.i(SOURCE, "Hooked ConnectivityService ($source) cls=${cls.name}") + } + + private fun initMethodCache() { + val intType = Int::class.javaPrimitiveType!! + val booleanType = Boolean::class.javaPrimitiveType!! + val naiClass = resolveConnectivityModuleClass("NetworkAgentInfo", "connectivity") + if (sdkInt >= 31) { + getVpnForUidMethod = findDeclaredMethod(cls, "getVpnForUid", intType) + if (getVpnForUidMethod == null) { + HookErrorStore.w(SOURCE, "getVpnForUid not found; falling back to underlying networks") + } + } + getVpnUnderlyingNetworksMethod = requireDeclaredMethod(cls, "getVpnUnderlyingNetworks", intType) + getNetworkAgentInfoForNetworkMethod = requireDeclaredMethod(cls, "getNetworkAgentInfoForNetwork", Network::class.java) + if (sdkInt >= 31) { + getFilteredNetworkInfoMethod = findDeclaredMethod( + cls, + "getFilteredNetworkInfo", + naiClass, + intType, + booleanType, + ) + if (getFilteredNetworkInfoMethod == null) { + HookErrorStore.w(SOURCE, "getFilteredNetworkInfo not found; network info sanitization disabled") + } + } + getDefaultNetworkMethod = requireDeclaredMethod(cls, "getDefaultNetwork") + isVPNMethod = requireDeclaredMethod(naiClass, "isVPN") + networkMethod = findDeclaredMethod(naiClass, "network") + if (networkMethod == null) { + HookErrorStore.w(SOURCE, "NetworkAgentInfo.network() not found; falling back to field access") + } + } + + // region Service Discovery + + private fun findConnectivityServiceClass(): Class<*>? { + val candidates = listOf( + "com.android.server.ConnectivityService", + ) + val loaders = listOf( + classLoader, + classLoader.parent, + Thread.currentThread().contextClassLoader, + ClassLoader.getSystemClassLoader(), + ClassLoader.getSystemClassLoader()?.parent, + ) + for (name in candidates) { + for (loader in loaders) { + try { + val found = if (loader != null) { + Class.forName(name, false, loader) + } else { + Class.forName(name) + } + HookErrorStore.i( + SOURCE, + "ConnectivityService class found: $name via ${loader?.javaClass?.name ?: "null"}", + ) + return found + } catch (_: Throwable) { + } + } + } + HookErrorStore.i(SOURCE, "ConnectivityService class not found in known classloaders") + return null + } + + private fun hookConnectivityServiceInitializer() { + if (sdkInt < 31 || sdkInt >= 33) { + HookErrorStore.d(SOURCE, "Skip ConnectivityServiceInitializer: sdk=$sdkInt (only exists in API 31-32)") + return + } + val candidates = listOf( + "com.android.server.ConnectivityServiceInitializer", + "com.android.server.ConnectivityServiceInitializerB", + ) + val loaders = listOf( + classLoader, + classLoader.parent, + Thread.currentThread().contextClassLoader, + ClassLoader.getSystemClassLoader(), + ClassLoader.getSystemClassLoader()?.parent, + ) + for (name in candidates) { + for (loader in loaders) { + val cls = try { + if (loader != null) { + Class.forName(name, false, loader) + } else { + Class.forName(name) + } + } catch (_: Throwable) { + null + } ?: continue + try { + if (initializerHooked.get()) { + return + } + XposedHelpers.findAndHookConstructor( + cls, + Context::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val instance = param.thisObject ?: return + val connectivity = findConnectivityServiceInstance(instance) ?: return + installHooks(connectivity.javaClass, "initializer_ctor") + } + }, + ) + XposedHelpers.findAndHookMethod( + cls, + "onStart", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val instance = param.thisObject ?: return + val connectivity = findConnectivityServiceInstance(instance) ?: return + installHooks(connectivity.javaClass, "initializer") + } + }, + ) + initializerHooked.set(true) + HookErrorStore.i( + SOURCE, + "Hooked $name (ctor/onStart) via ${loader?.javaClass?.name ?: "null"}", + ) + return + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook $name failed: ${e.message}", e) + } + } + } + HookErrorStore.d(SOURCE, "ConnectivityServiceInitializer not found in known classloaders") + } + + private fun hookClassLoaderFallback() { + if (classLoadUnhook != null) { + return + } + try { + classLoadUnhook = XposedHelpers.findAndHookMethod( + ClassLoader::class.java, + "loadClass", + String::class.java, + Boolean::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val name = param.args[0] as? String ?: return + if (hooked.get()) { + classLoadUnhook?.unhook() + classLoadUnhook = null + return + } + when (name) { + "com.android.server.ConnectivityService" -> { + val cls = param.result as? Class<*> ?: return + HookErrorStore.i( + SOURCE, + "ConnectivityService loaded via ${param.thisObject.javaClass.name}", + ) + installHooks(cls, "loadClass") + classLoadUnhook?.unhook() + classLoadUnhook = null + } + "com.android.server.ConnectivityServiceInitializer", + "com.android.server.ConnectivityServiceInitializerB", + -> { + if (sdkInt < 31) return + if (initializerHooked.get()) return + val cls = param.result as? Class<*> ?: return + HookErrorStore.i( + SOURCE, + "ConnectivityServiceInitializer loaded via ${param.thisObject.javaClass.name}", + ) + hookConnectivityServiceInitializerClass(cls) + } + } + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked ClassLoader.loadClass for ConnectivityService") + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook ClassLoader.loadClass failed: ${e.message}", e) + } + } + + private fun tryHookFromServiceManager() { + if (hooked.get()) return + val binder = try { + checkServiceMethod.invoke(null, Context.CONNECTIVITY_SERVICE) as? IBinder + } catch (_: Throwable) { + null + } + if (binder != null) { + HookErrorStore.i( + SOURCE, + "ConnectivityService binder from ServiceManager: ${binder.javaClass.name}", + ) + installHooks(binder.javaClass, "ServiceManager.checkService") + return + } + hookServiceManagerAddService() + } + + private fun hookServiceManagerAddService() { + if (!serviceManagerHooked.compareAndSet(false, true)) { + return + } + try { + val serviceManager = Class.forName("android.os.ServiceManager") + XposedHelpers.findAndHookMethod( + serviceManager, + "addService", + String::class.java, + IBinder::class.java, + Boolean::class.javaPrimitiveType, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val name = param.args[0] as? String ?: return + if (name != Context.CONNECTIVITY_SERVICE) return + val binder = param.args[1] as? IBinder ?: return + HookErrorStore.i( + SOURCE, + "ConnectivityService registered: ${binder.javaClass.name}", + ) + installHooks(binder.javaClass, "ServiceManager.addService") + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked ServiceManager.addService for ConnectivityService") + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook ServiceManager.addService failed: ${e.message}", e) + } + } + + private fun hookConnectivityServiceInitializerClass(cls: Class<*>) { + if (sdkInt < 31) return + if (initializerHooked.get()) return + try { + XposedHelpers.findAndHookConstructor( + cls, + Context::class.java, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val instance = param.thisObject ?: return + val connectivity = findConnectivityServiceInstance(instance) ?: return + installHooks(connectivity.javaClass, "initializer_ctor") + } + }, + ) + XposedHelpers.findAndHookMethod( + cls, + "onStart", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + if (hooked.get()) return + val instance = param.thisObject ?: return + val connectivity = findConnectivityServiceInstance(instance) ?: return + installHooks(connectivity.javaClass, "initializer") + } + }, + ) + initializerHooked.set(true) + HookErrorStore.i(SOURCE, "Hooked ${cls.name} (ctor/onStart) via loadClass") + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Hook ${cls.name} via loadClass failed: ${e.message}", e) + } + } + + private fun findConnectivityServiceInstance(instance: Any): Any? { + try { + val direct = XposedHelpers.getObjectField(instance, "mConnectivity") + if (direct != null) { + return direct + } + } catch (_: Throwable) { + } + return try { + val fields = instance.javaClass.declaredFields + for (field in fields) { + if (field.type.name.endsWith(".ConnectivityService")) { + field.isAccessible = true + val value = field.get(instance) + if (value != null) { + return value + } + } + } + null + } catch (_: Throwable) { + null + } + } + + // endregion + + // region Helper Methods + + fun shouldHide(connectivityService: Any, uid: Int): Boolean { + if (!PrivilegeSettingsStore.isEnabled()) { + logSkipOnce(uid, "hide_disabled", "Skip hide: uid=$uid hide settings disabled") + return false + } + if (!PrivilegeSettingsStore.isUidSelected(uid)) { + logSkipOnce(uid, "hide_not_selected", "Skip hide: uid=$uid not in hide list") + return false + } + if (VpnAppStore.isVpnUidExcludeSelf(uid)) { + logSkipOnce(uid, "uid_vpn_app", "Skip hide: uid=$uid vpn app") + return false + } + val hasVpn = hasVpnForUid(connectivityService, uid) + if (!hasVpn) { + logSkipOnce(uid, "uid_no_vpn", "Skip hide: uid=$uid noVpnForUid") + } + return hasVpn + } + + fun hasVpnForUid(connectivityService: Any, uid: Int): Boolean { + if (sdkInt >= 31) { + val vpnForUidMethod = getVpnForUidMethod + if (vpnForUidMethod != null) { + return vpnForUidMethod.invoke(connectivityService, uid) != null + } + } + @Suppress("UNCHECKED_CAST") + val networks = getVpnUnderlyingNetworksMethod.invoke(connectivityService, uid) as? Array + return networks != null && networks.isNotEmpty() + } + + fun isVpnNetwork(connectivityService: Any, network: Network): Boolean { + val nai = getNetworkAgentInfoForNetworkMethod.invoke(connectivityService, network) ?: return false + return isVpnNai(nai) + } + + fun isVpnNai(nai: Any): Boolean = isVPNMethod.invoke(nai) as Boolean + + fun getUnderlyingNetwork(connectivityService: Any, uid: Int): Network? { + val nai = getUnderlyingNai(connectivityService, uid) ?: return null + val method = networkMethod + return if (method != null) { + method.invoke(nai) as Network? + } else { + XposedHelpers.getObjectField(nai, "network") as? Network + } + } + + fun getUnderlyingLinkProperties(connectivityService: Any, uid: Int): LinkProperties? { + val nai = getUnderlyingNai(connectivityService, uid) ?: return null + val lp = XposedHelpers.getObjectField(nai, "linkProperties") as LinkProperties? ?: return null + return VpnSanitizer.cloneLinkProperties(lp) + } + + fun getUnderlyingNetworkInfo(connectivityService: Any, uid: Int): NetworkInfo? { + val nai = getUnderlyingNai(connectivityService, uid) ?: return null + val method = getFilteredNetworkInfoMethod + if (method != null) { + return method.invoke(connectivityService, nai, uid, false) as NetworkInfo? + } + return XposedHelpers.getObjectField(nai, "networkInfo") as? NetworkInfo + } + + fun getUnderlyingNai(connectivityService: Any, uid: Int): Any? { + @Suppress("UNCHECKED_CAST") + val networks = getVpnUnderlyingNetworksMethod.invoke(connectivityService, uid) as? Array + if (networks != null && networks.isNotEmpty()) { + return getNetworkAgentInfoForNetworkMethod.invoke(connectivityService, networks[0]) + } + val defaultNai = getDefaultNetworkMethod.invoke(connectivityService) + if (defaultNai != null && !isVpnNai(defaultNai)) { + return defaultNai + } + return null + } + + private fun findDeclaredMethod(target: Class<*>, name: String, vararg parameterTypes: Class<*>): Method? { + var current: Class<*>? = target + while (current != null) { + try { + return current.getDeclaredMethod(name, *parameterTypes).apply { isAccessible = true } + } catch (_: NoSuchMethodException) { + current = current.superclass + } + } + return null + } + + private fun requireDeclaredMethod(target: Class<*>, name: String, vararg parameterTypes: Class<*>): Method = findDeclaredMethod(target, name, *parameterTypes) + ?: throw NoSuchMethodException("${target.name}#$name") + + /** + * Resolves a class from the Connectivity module, handling APEX package rewriting. + * + * When the Connectivity module runs as an APEX (Android 12+), all classes get prefixed + * with "android.net.connectivity.". This method derives the correct prefix from + * the already-loaded ConnectivityService class. + * + * @param simpleClassName Simple class name (e.g., "ProxyTracker") + * @param subPackage Sub-package under com.android.server (e.g., "connectivity"), or null + */ + fun resolveConnectivityModuleClass(simpleClassName: String, subPackage: String? = null): Class<*> { + val base = cls.name + val serverPackage = if (base.endsWith(".ConnectivityService")) { + base.removeSuffix(".ConnectivityService") + } else { + base.substringBeforeLast(".ConnectivityService", base) + } + + val fullClassName = if (subPackage != null) { + "$serverPackage.$subPackage.$simpleClassName" + } else { + "$serverPackage.$simpleClassName" + } + + return XposedHelpers.findClass(fullClassName, connectivityClassLoader) + } + + fun resolveNriAndNaiClasses(): Pair, Class<*>> { + val nriClass = XposedHelpers.findClass( + cls.name + '$' + "NetworkRequestInfo", + connectivityClassLoader, + ) + val naiClass = resolveConnectivityModuleClass("NetworkAgentInfo", "connectivity") + return Pair(nriClass, naiClass) + } + + fun getAsUid(nri: Any): Int { + val fieldName = if (sdkInt >= 31) "mAsUid" else "mUid" + return XposedHelpers.getIntField(nri, fieldName) + } + + fun logSkipOnce(uid: Int, reason: String, message: String) { + val key = "$uid:$reason" + if (skipLogKeys.putIfAbsent(key, true) == null) { + HookErrorStore.d(SOURCE, message) + } + } + + // endregion +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt new file mode 100644 index 0000000000..2f7800868e --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkCapabilities+writeToParcel.kt @@ -0,0 +1,120 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.net.NetworkCapabilities +import android.os.Binder +import android.os.Parcel +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.HookStatusStore +import io.nekohasekai.sfa.xposed.VpnHideContext +import io.nekohasekai.sfa.xposed.VpnSanitizer +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook +import io.nekohasekai.sfa.xposed.hooks.XHook + +class HookNetworkCapabilitiesWriteToParcel : XHook { + private companion object { + private const val SOURCE = "HookNCWriteToParcel" + } + + private val copyCtor by lazy { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + NetworkCapabilities::class.java.getDeclaredConstructor( + NetworkCapabilities::class.java, + Long::class.javaPrimitiveType, + ).apply { isAccessible = true } + } else { + NetworkCapabilities::class.java.getDeclaredConstructor( + NetworkCapabilities::class.java, + ).apply { isAccessible = true } + } + } + private val removeTransportTypeMethod by lazy { + NetworkCapabilities::class.java.getMethod("removeTransportType", Int::class.javaPrimitiveType) + } + private val addCapabilityMethod by lazy { + NetworkCapabilities::class.java.getMethod("addCapability", Int::class.javaPrimitiveType) + } + + private val inWrite = ThreadLocal.withInitial { false } + + override fun injectHook() { + XposedHelpers.findAndHookMethod( + NetworkCapabilities::class.java, + "writeToParcel", + Parcel::class.java, + Int::class.javaPrimitiveType!!, + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + if (inWrite.get() == true) { + return + } + val targetUid = VpnHideContext.consumeTargetUid() + val shouldHide = when { + targetUid != null -> VpnSanitizer.shouldHide(targetUid) + else -> VpnSanitizer.shouldHide(Binder.getCallingUid()) + } + if (!shouldHide) { + return + } + val caps = param.thisObject as NetworkCapabilities + val sanitized = copyNetworkCapabilities(caps) + sanitizeNetworkCapabilities(sanitized) + HookStatusStore.markPatched() + inWrite.set(true) + try { + XposedBridge.invokeOriginalMethod(param.method, sanitized, param.args) + param.result = null + } finally { + inWrite.set(false) + } + } + }, + ) + HookStatusStore.markHookActive() + HookErrorStore.i(SOURCE, "Hooked NetworkCapabilities.writeToParcel (sender)") + } + + private fun copyNetworkCapabilities(caps: NetworkCapabilities): NetworkCapabilities = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + copyCtor.newInstance(caps, 0L) as NetworkCapabilities + } else { + copyCtor.newInstance(caps) as NetworkCapabilities + } + + private fun sanitizeNetworkCapabilities(caps: NetworkCapabilities) { + removeTransportTypeMethod.invoke(caps, NetworkCapabilities.TRANSPORT_VPN) + addCapabilityMethod.invoke(caps, NetworkCapabilities.NET_CAPABILITY_NOT_VPN) + clearVpnTransportInfo(caps) + clearUnderlyingNetworks(caps) + clearOwnerUid(caps) + } + + private fun clearVpnTransportInfo(caps: NetworkCapabilities) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.Q) { + return + } + val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mTransportInfo") + val info = field.get(caps) ?: return + if (info.javaClass.name.contains("VpnTransportInfo")) { + field.set(caps, null) + } + } + + private fun clearUnderlyingNetworks(caps: NetworkCapabilities) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mUnderlyingNetworks") + field.set(caps, null) + } else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + val field = XposedHelpers.findFieldIfExists(NetworkCapabilities::class.java, "mUnderlyingNetworks") + field?.set(caps, null) + } + } + + private fun clearOwnerUid(caps: NetworkCapabilities) { + if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R) { + return + } + val field = XposedHelpers.findField(NetworkCapabilities::class.java, "mOwnerUid") + field.setInt(caps, android.os.Process.INVALID_UID) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt new file mode 100644 index 0000000000..f9b7a2086a --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpn/NetworkInterface+getName.kt @@ -0,0 +1,299 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpn + +import android.system.Os +import android.system.OsConstants +import android.system.StructTimeval +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook +import io.nekohasekai.sfa.xposed.hooks.XHook +import java.io.FileDescriptor +import java.net.SocketAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.util.concurrent.atomic.AtomicInteger + +class HookNetworkInterfaceGetName(private val classLoader: ClassLoader) : XHook { + private companion object { + private const val SOURCE = "HookNetworkInterfaceGetName" + private const val MAX_NAME_LEN = 15 + private const val MAX_SUFFIX = 63 + private const val NLMSG_HEADER_LEN = 16 + private const val IFINFO_MSG_LEN = 16 + private const val NLA_HEADER_LEN = 4 + private const val RTM_NEWLINK = 16 + private const val IFLA_IFNAME = 3 + private const val NLM_F_REQUEST = 0x1 + private const val NLM_F_ACK = 0x4 + private const val NLMSG_ERROR = 2 + private const val IFF_UP = 0x1 + } + + private val netlinkSocketAddressClass by lazy { Class.forName("android.system.NetlinkSocketAddress") } + private val netlinkSocketAddressCtor by lazy { + netlinkSocketAddressClass.getConstructor(Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) + } + + private val seq = AtomicInteger(1) + + override fun injectHook() { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + hookJniGetNameApi33Plus() + } else { + hookJniGetNameLegacy() + } + } + + private fun hookJniGetNameApi33Plus() { + val vpnClass = findVpnClass() + val depsClass = XposedHelpers.findClass("${vpnClass.name}\$Dependencies", classLoader) + XposedHelpers.findAndHookMethod( + depsClass, + "jniGetName", + vpnClass, + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + processJniGetNameResult(param) + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked ${depsClass.name}.jniGetName (API 33+)") + } + + private fun hookJniGetNameLegacy() { + val cls = findVpnClass() + XposedHelpers.findAndHookMethod( + cls, + "jniGetName", + Int::class.javaPrimitiveType, + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + processJniGetNameResult(param) + } + }, + ) + HookErrorStore.i(SOURCE, "Hooked ${cls.name}.jniGetName (legacy)") + } + + private fun processJniGetNameResult(param: de.robv.android.xposed.XC_MethodHook.MethodHookParam) { + val result = param.result + if (result !is String) { + if (result != null) { + HookErrorStore.e(SOURCE, "jniGetName returned unexpected type: ${result.javaClass.name}") + } + return + } + if (!PrivilegeSettingsStore.shouldRenameInterface()) return + if (!isTunInterface(result)) return + val prefix = PrivilegeSettingsStore.interfacePrefix() + val renamed = renameInterface(result, prefix) ?: return + param.result = renamed + } + + private fun findVpnClass(): Class<*> = XposedHelpers.findClass("com.android.server.connectivity.Vpn", classLoader) + + private fun isTunInterface(name: String): Boolean = name.startsWith("tun") + + private fun renameInterface(oldName: String, prefix: String): String? { + val oldIndex = getInterfaceIndex(oldName) + if (oldIndex <= 0) { + HookErrorStore.e(SOURCE, "rename interface: old name not found (old=$oldName)") + return null + } + val newName = findAvailableName(prefix) + if (newName == null) { + HookErrorStore.e(SOURCE, "rename interface: no available name (prefix=$prefix)") + return null + } + if (newName == oldName) { + return oldName + } + if (!renameWithNetlink(oldIndex, newName)) { + HookErrorStore.e(SOURCE, "rename failed: $oldName -> $newName") + return null + } + val newIndex = getInterfaceIndex(newName) + if (newIndex <= 0) { + HookErrorStore.e( + SOURCE, + "rename interface: new name not found (old=$oldName index=$oldIndex)", + ) + return null + } + HookErrorStore.i(SOURCE, "rename interface: $oldName -> $newName") + return newName + } + + private fun getInterfaceIndex(name: String): Int = Os.if_nametoindex(name) + + private fun findAvailableName(prefix: String): String? { + val base = prefix.trim() + if (base.isEmpty()) { + return null + } + for (i in 0..MAX_SUFFIX) { + val candidate = buildInterfaceName(base, i) ?: return null + if (getInterfaceIndex(candidate) == 0) { + return candidate + } + } + return null + } + + private fun buildInterfaceName(prefix: String, suffix: Int): String? { + val suffixText = suffix.toString() + val maxPrefixLen = MAX_NAME_LEN - suffixText.length + if (maxPrefixLen <= 0) { + return null + } + val trimmed = if (prefix.length > maxPrefixLen) { + prefix.substring(0, maxPrefixLen) + } else { + prefix + } + return trimmed + suffixText + } + + private fun renameWithNetlink(index: Int, newName: String): Boolean { + val fd = openNetlinkSocket() + try { + val renameResult = sendNetlinkMessage( + fd, + buildLinkMessage(index, newName, 0, 0, seq.getAndIncrement()), + OsConstants.EBUSY, + ) ?: return false + if (renameResult == 0) { + return true + } + if (renameResult != OsConstants.EBUSY) { + HookErrorStore.e(SOURCE, "rename interface: netlink ack errno=$renameResult") + return false + } + val downResult = sendNetlinkMessage( + fd, + buildLinkMessage(index, null, 0, IFF_UP, seq.getAndIncrement()), + ) ?: return false + if (downResult != 0) { + HookErrorStore.e(SOURCE, "rename interface: set down failed errno=$downResult") + return false + } + val retryResult = sendNetlinkMessage( + fd, + buildLinkMessage(index, newName, 0, 0, seq.getAndIncrement()), + ) ?: return false + if (retryResult != 0) { + HookErrorStore.e(SOURCE, "rename interface: retry failed errno=$retryResult") + return false + } + val upResult = sendNetlinkMessage( + fd, + buildLinkMessage(index, null, IFF_UP, IFF_UP, seq.getAndIncrement()), + ) + if (upResult != null && upResult != 0) { + HookErrorStore.w(SOURCE, "rename interface: set up failed errno=$upResult") + } + return true + } catch (e: Throwable) { + HookErrorStore.e(SOURCE, "rename interface: netlink exception", e) + return false + } finally { + try { + Os.close(fd) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "close netlink socket failed", e) + } + } + } + + private fun openNetlinkSocket(): FileDescriptor { + val fd = Os.socket(OsConstants.AF_NETLINK, OsConstants.SOCK_RAW, OsConstants.NETLINK_ROUTE) + Os.setsockoptTimeval( + fd, + OsConstants.SOL_SOCKET, + OsConstants.SO_RCVTIMEO, + StructTimeval.fromMillis(200), + ) + val address = buildNetlinkAddress() + Os.connect(fd, address) + return fd + } + + private fun buildNetlinkAddress(): SocketAddress = netlinkSocketAddressCtor.newInstance(0, 0) as SocketAddress + + private fun buildLinkMessage(index: Int, ifName: String?, flags: Int, change: Int, seq: Int): ByteArray { + val nameBytes = ifName?.let { (it + "\u0000").toByteArray(Charsets.US_ASCII) } + val attrLen = if (nameBytes != null) NLA_HEADER_LEN + nameBytes.size else 0 + val attrAligned = align(attrLen) + val totalLength = NLMSG_HEADER_LEN + IFINFO_MSG_LEN + attrAligned + val buffer = ByteBuffer.allocate(totalLength).order(ByteOrder.nativeOrder()) + buffer.putInt(totalLength) + buffer.putShort(RTM_NEWLINK.toShort()) + buffer.putShort((NLM_F_REQUEST or NLM_F_ACK).toShort()) + buffer.putInt(seq) + buffer.putInt(Os.getpid()) + buffer.put(OsConstants.AF_UNSPEC.toByte()) + buffer.put(0.toByte()) + buffer.putShort(0) + buffer.putInt(index) + buffer.putInt(flags) + buffer.putInt(change) + if (nameBytes != null) { + buffer.putShort(attrLen.toShort()) + buffer.putShort(IFLA_IFNAME.toShort()) + buffer.put(nameBytes) + val pad = attrAligned - attrLen + repeat(pad) { + buffer.put(0.toByte()) + } + } + return buffer.array() + } + + private fun align(length: Int): Int = (length + 3) and -4 + + private fun sendNetlinkMessage(fd: FileDescriptor, message: ByteArray, suppressErrno: Int? = null): Int? { + Os.write(fd, message, 0, message.size) + val ack = readNetlinkAck(fd) + if (ack == null) { + HookErrorStore.e(SOURCE, "rename interface: netlink ack missing") + return null + } + if (ack.errno != 0 && ack.errno != suppressErrno) { + HookErrorStore.e( + SOURCE, + "rename interface: netlink ack errno=${ack.errno} seq=${ack.seq} pid=${ack.pid}", + ) + } + return ack.errno + } + + private data class NetlinkAck(val errno: Int, val seq: Int, val pid: Int) + + private fun readNetlinkAck(fd: FileDescriptor): NetlinkAck? { + val buffer = ByteArray(4096) + val length = Os.read(fd, buffer, 0, buffer.size) + if (length <= 0 || length < NLMSG_HEADER_LEN) { + return null + } + val byteBuffer = ByteBuffer.wrap(buffer, 0, length).order(ByteOrder.nativeOrder()) + val msgLen = byteBuffer.int + val msgType = byteBuffer.short.toInt() and 0xFFFF + byteBuffer.short + val msgSeq = byteBuffer.int + val msgPid = byteBuffer.int + if (msgLen < NLMSG_HEADER_LEN || msgLen > length) { + return null + } + if (msgType != NLMSG_ERROR) { + return NetlinkAck(0, msgSeq, msgPid) + } + if (byteBuffer.remaining() < 4) { + return null + } + val error = byteBuffer.int + val errno = if (error == 0) 0 else -error + return NetlinkAck(errno, msgSeq, msgPid) + } +} diff --git a/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt new file mode 100644 index 0000000000..24c96c2e04 --- /dev/null +++ b/sing-box/clients/android/app/src/main/java/io/nekohasekai/sfa/xposed/hooks/hidevpnapp/PackageManager+getInstalledPackages.kt @@ -0,0 +1,299 @@ +package io.nekohasekai.sfa.xposed.hooks.hidevpnapp + +import android.content.pm.ResolveInfo +import android.os.Binder +import android.os.Build +import android.os.Process +import de.robv.android.xposed.XC_MethodHook +import de.robv.android.xposed.XposedBridge +import de.robv.android.xposed.XposedHelpers +import io.nekohasekai.sfa.BuildConfig +import io.nekohasekai.sfa.xposed.HookErrorStore +import io.nekohasekai.sfa.xposed.PrivilegeSettingsStore +import io.nekohasekai.sfa.xposed.VpnAppStore +import io.nekohasekai.sfa.xposed.hooks.SafeMethodHook +import io.nekohasekai.sfa.xposed.hooks.XHook +import java.lang.reflect.Method + +class HookPackageManagerGetInstalledPackages(private val classLoader: ClassLoader) : XHook { + private companion object { + private const val SOURCE = "HookPMGetInstalledPackages" + private const val PER_USER_RANGE = 100000 + } + + @Volatile + private var lastPackageNameClass: Class<*>? = null + private var getPackageNameMethod: Method? = null + + override fun injectHook() { + val hooked = ArrayList() + val sdk = Build.VERSION.SDK_INT + when { + // VANILLA_ICE_CREAM + sdk >= 35 -> { + hookAppsFilter33Plus(hooked) + hookArchivedPackageInternal(hooked) + } + sdk >= Build.VERSION_CODES.TIRAMISU -> { + hookAppsFilter33Plus(hooked) + } + sdk >= Build.VERSION_CODES.R -> { + hookAppsFilter30(hooked) + } + else -> { + hookPmsLegacy(hooked) + } + } + if (hooked.isNotEmpty()) { + HookErrorStore.i(SOURCE, "Hooked hide applist: ${hooked.joinToString()}") + } else { + HookErrorStore.w(SOURCE, "Hide applist hook not applied") + } + } + + private fun hookAppsFilter33Plus(hooked: MutableList) { + val cls = XposedHelpers.findClassIfExists("com.android.server.pm.AppsFilterImpl", classLoader) + if (cls == null) { + HookErrorStore.e(SOURCE, "Class com.android.server.pm.AppsFilterImpl not found") + return + } + val unhooks = try { + XposedBridge.hookAllMethods( + cls, + "shouldFilterApplication", + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val callingUid = param.args[1] as Int + val callerPackages = getCallerPackages(callingUid) ?: return + val target = param.args[3]!! + val targetPackage = extractPackageName(target) ?: return + if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { + param.result = true + } + } + }, + ) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip AppsFilterImpl.shouldFilterApplication: ${e.message}", e) + emptySet() + } + if (unhooks.isNotEmpty()) { + hooked.add("AppsFilterImpl.shouldFilterApplication") + } + } + + private fun hookAppsFilter30(hooked: MutableList) { + val cls = XposedHelpers.findClassIfExists("com.android.server.pm.AppsFilter", classLoader) + if (cls == null) { + HookErrorStore.e(SOURCE, "Class com.android.server.pm.AppsFilter not found") + return + } + val unhooks = try { + XposedBridge.hookAllMethods( + cls, + "shouldFilterApplication", + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val callingUid = param.args[0] as Int + val callerPackages = getCallerPackages(callingUid) ?: return + val target = param.args[2]!! + val targetPackage = extractPackageName(target) ?: return + if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { + param.result = true + } + } + }, + ) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip AppsFilter.shouldFilterApplication: ${e.message}", e) + emptySet() + } + if (unhooks.isNotEmpty()) { + hooked.add("AppsFilter.shouldFilterApplication") + } + } + + private fun hookArchivedPackageInternal(hooked: MutableList) { + val cls = XposedHelpers.findClassIfExists("com.android.server.pm.PackageManagerService", classLoader) + if (cls == null) { + HookErrorStore.e(SOURCE, "Class com.android.server.pm.PackageManagerService not found") + return + } + val unhooks = try { + XposedBridge.hookAllMethods( + cls, + "getArchivedPackageInternal", + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val callingUid = Binder.getCallingUid() + val callerPackages = getCallerPackages(callingUid) ?: return + val targetPackage = param.args[0]!!.toString() + if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { + param.result = null + } + } + }, + ) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip PackageManagerService.getArchivedPackageInternal: ${e.message}", e) + emptySet() + } + if (unhooks.isNotEmpty()) { + hooked.add("PackageManagerService.getArchivedPackageInternal") + } + } + + private fun hookPmsLegacy(hooked: MutableList) { + val cls = XposedHelpers.findClassIfExists("com.android.server.pm.PackageManagerService", classLoader) + if (cls == null) { + HookErrorStore.e(SOURCE, "Class com.android.server.pm.PackageManagerService not found") + return + } + val filterHooks = try { + XposedBridge.hookAllMethods( + cls, + "filterAppAccessLPr", + object : SafeMethodHook(SOURCE) { + override fun beforeHook(param: MethodHookParam) { + val callingUid = param.args[1] as Int + val callerPackages = getCallerPackages(callingUid) ?: return + val target = param.args[0]!! + val targetPackage = extractPackageName(target) ?: return + if (shouldHidePackage(callingUid, callerPackages, targetPackage)) { + param.result = true + } + } + }, + ) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip PackageManagerService.filterAppAccessLPr: ${e.message}", e) + emptySet() + } + if (filterHooks.isNotEmpty()) { + hooked.add("PackageManagerService.filterAppAccessLPr") + } + + val resolutionHooks = try { + XposedBridge.hookAllMethods( + cls, + "applyPostResolutionFilter", + object : SafeMethodHook(SOURCE) { + override fun afterHook(param: MethodHookParam) { + val callingUid = param.args[3] as Int + val callerPackages = getCallerPackages(callingUid) ?: return + val rawResult = param.result ?: return + when (rawResult) { + is MutableCollection<*> -> { + @Suppress("UNCHECKED_CAST") + val result = rawResult as MutableCollection + val iterator = result.iterator() + while (iterator.hasNext()) { + val info = iterator.next() + val targetPackage = with(info) { + activityInfo?.packageName + ?: serviceInfo?.packageName + ?: providerInfo?.packageName + ?: resolvePackageName + } + if (targetPackage != null && + shouldHidePackage(callingUid, callerPackages, targetPackage) + ) { + iterator.remove() + } + } + } + is List<*> -> { + val filtered = rawResult.filterNot { item -> + val info = item as? ResolveInfo ?: return@filterNot false + val targetPackage = with(info) { + activityInfo?.packageName + ?: serviceInfo?.packageName + ?: providerInfo?.packageName + ?: resolvePackageName + } + targetPackage != null && + shouldHidePackage(callingUid, callerPackages, targetPackage) + } + param.result = filtered + } + } + } + }, + ) + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "Skip PackageManagerService.applyPostResolutionFilter: ${e.message}", e) + emptySet() + } + if (resolutionHooks.isNotEmpty()) { + hooked.add("PackageManagerService.applyPostResolutionFilter") + } + } + + private fun getCallerPackages(callingUid: Int): List? { + if (callingUid < Process.FIRST_APPLICATION_UID) { + return null + } + if (!PrivilegeSettingsStore.shouldHideUid(callingUid)) { + return null + } + val packages = VpnAppStore.getPackagesForUid(callingUid) + if (packages.isEmpty()) { + return null + } + if (packages.contains(BuildConfig.APPLICATION_ID)) { + return null + } + return packages + } + + private fun shouldHidePackage( + callingUid: Int, + callerPackages: List, + targetPackage: String, + ): Boolean { + if (callerPackages.contains(targetPackage)) { + return false + } + val userId = callingUid / PER_USER_RANGE + if (!VpnAppStore.isVpnPackage(targetPackage, userId)) { + return false + } + return true + } + + private fun extractPackageName(arg: Any?): String? { + if (arg == null) return null + try { + val argClass = arg.javaClass + val method = if (lastPackageNameClass == argClass && getPackageNameMethod != null) { + getPackageNameMethod!! + } else { + argClass.getMethod("getPackageName").also { + lastPackageNameClass = argClass + getPackageNameMethod = it + } + } + val result = method.invoke(arg) as String? + if (!result.isNullOrEmpty()) { + return result + } + } catch (_: NoSuchMethodException) { + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "extractPackageName via getPackageName() failed for ${arg.javaClass.name}", e) + } + val fields = arrayOf("packageName", "mPackageName", "name", "mName") + for (name in fields) { + val field = XposedHelpers.findFieldIfExists(arg.javaClass, name) ?: continue + try { + val result = field.get(arg) as String? + if (!result.isNullOrEmpty()) { + return result + } + } catch (e: Throwable) { + HookErrorStore.w(SOURCE, "extractPackageName via field $name failed for ${arg.javaClass.name}", e) + } + } + HookErrorStore.w(SOURCE, "extractPackageName failed for ${arg.javaClass.name}") + return null + } +} diff --git a/sing-box/clients/android/app/src/main/res/drawable/ic_filter_list_24.xml b/sing-box/clients/android/app/src/main/res/drawable/ic_filter_list_24.xml new file mode 100644 index 0000000000..344421f0fb --- /dev/null +++ b/sing-box/clients/android/app/src/main/res/drawable/ic_filter_list_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/res/drawable/ic_pause_24.xml b/sing-box/clients/android/app/src/main/res/drawable/ic_pause_24.xml new file mode 100644 index 0000000000..7b10b5607f --- /dev/null +++ b/sing-box/clients/android/app/src/main/res/drawable/ic_pause_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/res/drawable/ic_search_24.xml b/sing-box/clients/android/app/src/main/res/drawable/ic_search_24.xml new file mode 100644 index 0000000000..3285a07714 --- /dev/null +++ b/sing-box/clients/android/app/src/main/res/drawable/ic_search_24.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/res/layout/activity_add_profile.xml b/sing-box/clients/android/app/src/main/res/layout/activity_add_profile.xml deleted file mode 100644 index 4657c248c6..0000000000 --- a/sing-box/clients/android/app/src/main/res/layout/activity_add_profile.xml +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/sing-box/clients/android/app/src/main/res/layout/activity_config_override.xml b/sing-box/clients/android/app/src/main/res/layout/activity_config_override.xml deleted file mode 100644 index 163350ec40..0000000000 --- a/sing-box/clients/android/app/src/main/res/layout/activity_config_override.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -