diff --git a/package-lock.json b/package-lock.json index e767aa445..faeea04e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,9 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.2", + "file": "file:src/plugins/file", "mini-css-extract-plugin": "^2.9.3", + "native_file": "file:src/plugins/nativeFile", "path-browserify": "^1.0.1", "postcss-loader": "^8.1.1", "prettier": "^3.6.2", @@ -81,6 +83,8 @@ "sass-loader": "^16.0.5", "style-loader": "^4.0.0", "terminal": "^0.1.4", + "ts-loader": "^9.5.4", + "typescript": "^5.9.3", "webpack": "^5.101.0", "webpack-cli": "^6.0.1" } @@ -128,7 +132,6 @@ "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2959,7 +2962,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/markdown-it": { "version": "14.1.2", @@ -2976,7 +2980,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/node": { "version": "24.2.1", @@ -3289,8 +3294,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", @@ -3321,7 +3325,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3355,7 +3358,6 @@ "version": "8.12.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -3736,7 +3738,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001733", "electron-to-chromium": "^1.5.199", @@ -3839,7 +3840,6 @@ "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -4106,9 +4106,9 @@ } }, "node_modules/cordova-android/node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -5125,6 +5125,10 @@ "reusify": "^1.0.4" } }, + "node_modules/file": { + "resolved": "src/plugins/file", + "link": true + }, "node_modules/filesize": { "version": "11.0.2", "resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.2.tgz", @@ -6020,7 +6024,6 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -6348,6 +6351,10 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/native_file": { + "resolved": "src/plugins/nativeFile", + "link": true + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -6906,7 +6913,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", @@ -7061,7 +7067,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -7228,7 +7233,6 @@ "version": "6.12.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -8061,7 +8065,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8098,6 +8101,126 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ts-loader": { + "version": "9.5.4", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", + "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ts-loader/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/ts-loader/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-loader/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ts-loader/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ts-loader/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tuf-js": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.0.0.tgz", @@ -8119,6 +8242,20 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", @@ -8333,7 +8470,6 @@ "integrity": "sha512-B4t+nJqytPeuZlHuIKTbalhljIFXeNRqrUGAQgTGlfOl2lXXKXw+yZu6bicycP+PUlM44CxBjCFD6aciKFT3LQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -8383,7 +8519,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -8757,6 +8892,11 @@ "extraneous": true, "license": "MIT" }, + "src/plugins/file": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, "src/plugins/ftp": { "name": "cordova-plugin-ftp", "version": "1.1.1", @@ -8769,6 +8909,12 @@ "dev": true, "license": "ISC" }, + "src/plugins/nativeFile": { + "name": "native_file", + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, "src/plugins/proot": { "name": "com.foxdebug.acode.rk.exec.proot", "version": "1.0.0", diff --git a/package.json b/package.json index 1de0509ce..ee69a4228 100644 --- a/package.json +++ b/package.json @@ -35,10 +35,11 @@ "cordova-plugin-websocket": {}, "cordova-plugin-buildinfo": {}, "cordova-plugin-browser": {}, + "com.foxdebug.acode.rk.exec.terminal": {}, + "com.foxdebug.acode.rk.exec.proot": {}, "cordova-plugin-sftp": {}, "cordova-plugin-system": {}, - "com.foxdebug.acode.rk.exec.terminal": {}, - "com.foxdebug.acode.rk.exec.proot": {} + "com.foxdebug.acode.rk.file": {} }, "platforms": [ "android" @@ -80,7 +81,9 @@ "cordova-plugin-system": "file:src/plugins/system", "cordova-plugin-websocket": "file:src/plugins/websocket", "css-loader": "^7.1.2", + "file": "file:src/plugins/file", "mini-css-extract-plugin": "^2.9.3", + "native_file": "file:src/plugins/nativeFile", "path-browserify": "^1.0.1", "postcss-loader": "^8.1.1", "prettier": "^3.6.2", @@ -90,6 +93,8 @@ "sass-loader": "^16.0.5", "style-loader": "^4.0.0", "terminal": "^0.1.4", + "ts-loader": "^9.5.4", + "typescript": "^5.9.3", "webpack": "^5.101.0", "webpack-cli": "^6.0.1" }, @@ -129,4 +134,4 @@ "yargs": "^18.0.0" }, "browserslist": "cover 100%,not android < 5" -} \ No newline at end of file +} diff --git a/src/components/terminal/terminalManager.js b/src/components/terminal/terminalManager.js index 9c2f424a1..94c1eae94 100644 --- a/src/components/terminal/terminalManager.js +++ b/src/components/terminal/terminalManager.js @@ -588,10 +588,6 @@ class TerminalManager { // Remove from map this.terminals.delete(terminalId); - if (this.getAllTerminals().size <= 0) { - Executor.stopService(); - } - console.log(`Terminal ${terminalId} closed`); } catch (error) { console.error(`Error closing terminal ${terminalId}:`, error); diff --git a/src/fileSystem/FileObjectBuilder.ts b/src/fileSystem/FileObjectBuilder.ts new file mode 100644 index 000000000..664bfecb0 --- /dev/null +++ b/src/fileSystem/FileObjectBuilder.ts @@ -0,0 +1,22 @@ +import { FileObject } from "./fileObject"; +import { NativeFileWrapper } from "./NativeFileWrapper"; +import { SAFDocumentFile } from "./SAFDocumentFile"; + +export class FileObjectBuilder { + async build(uri: string): Promise { + if (uri.startsWith("file://") || uri.startsWith("cdvfile://")) { + return new Promise((resolve, reject) => { + const wrapper = new NativeFileWrapper(uri, (nativeFile) => { + resolve(nativeFile); + }); + + // Catch errors from the async ready promise + wrapper.ready.catch(reject); + }); + } + if (uri.startsWith("content://")) { + return new SAFDocumentFile(uri); + } + return null; + } +} diff --git a/src/fileSystem/NativeFileWrapper.ts b/src/fileSystem/NativeFileWrapper.ts new file mode 100644 index 000000000..749c4689d --- /dev/null +++ b/src/fileSystem/NativeFileWrapper.ts @@ -0,0 +1,314 @@ +import { Exception } from "sass"; +import { FileObject } from "./fileObject"; + +declare var cordova: any; + +//alternative for internalFs.js +export class NativeFileWrapper implements FileObject { + private path: string | undefined; + + //always check if fileobject is ready before calling any class function + ready: Promise; + + private removePrefix(str: string, prefix: string): string { + return str.startsWith(prefix) ? str.slice(prefix.length) : str; + } + + constructor( + absolutePathOrUri: string, + onReady: (obj: NativeFileWrapper) => void, + ) { + this.ready = (async () => { + let temp = absolutePathOrUri; + + //NOTE: only cvfiles are supported which are backed by the native filesystem files with http:// is not supported + if (absolutePathOrUri.startsWith("cdvfile://")) { + temp = await new Promise((resolve, reject) => { + // @ts-ignore + window.resolveLocalFileSystemURL( + absolutePathOrUri, + // nativeURL + (entry: any) => resolve(entry.nativeURL()), + reject, + ); + }); + } + + this.path = this.removePrefix(temp, "file://"); + + if (!this.path.startsWith("/")) { + throw new Error( + `Path "${absolutePathOrUri}" converted to "${this.path}" which is invalid since it does not start with / `, + ); + } + + onReady(this); + })(); + } + + async isMyChild(fileObject: FileObject): Promise { + if (!(fileObject instanceof NativeFileWrapper)) { + return false; + } + if (!(await this.isDirectory())) { + return false; + } + + return fileObject.getPath().includes(this.getPath()); + } + + private execPlugin(action: string, args: any[] = []): Promise { + //console.log(`[NativeFileWrapper] execPlugin called: action=${action}, args=${JSON.stringify(args)}`); + return new Promise((resolve, reject) => { + cordova.exec( + (result: any) => { + //console.log(`[NativeFileWrapper] execPlugin success: action=${action}, result=${JSON.stringify(result)}`); + resolve(result); + }, + (error: any) => { + console.error( + `[NativeFileWrapper] execPlugin error: action=${action}, error=${JSON.stringify(error)}`, + ); + reject(error); + }, + "nativeFile", + action, + [this.path, ...args], + ); + }); + } + + async canRead(): Promise { + const result = await this.execPlugin("canRead"); + //console.log(`[canRead] path=${this.path}, result=${result}`); + return result === 1; + } + + async canWrite(): Promise { + const result = await this.execPlugin("canWrite"); + //console.log(`[canWrite] path=${this.path}, result=${result}`); + return result === 1; + } + + async childByNameExists(name: string): Promise { + try { + const result = await this.execPlugin("childByNameExists", [name]); + //console.log(`[childByNameExists] path=${this.path}, name=${name}, result=${result}`); + return result === 1; + } catch (error) { + console.error( + `[childByNameExists] path=${this.path}, name=${name}, error=${error}`, + ); + return null; + } + } + + async createNewFile(): Promise { + try { + const result = await this.execPlugin("createNewFile"); + //console.log(`[createNewFile] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[createNewFile] path=${this.path}, error=${error}`); + return false; + } + } + + async delete(): Promise { + try { + const result = await this.execPlugin("delete"); + //console.log(`[delete] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[delete] path=${this.path}, error=${error}`); + return false; + } + } + + async exists(): Promise { + try { + const result = await this.execPlugin("exists"); + //console.log(`[exists] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[exists] path=${this.path}, error=${error}`); + return false; + } + } + + async getChildByName(name: string): Promise { + try { + const childPath = await this.execPlugin("getChildByName", [name]); + //console.log(`[getChildByName] path=${this.path}, name=${name}, childPath=${childPath}`); + if (childPath && childPath !== "") { + return new NativeFileWrapper(childPath, () => {}); + } + return null; + } catch (error) { + console.error( + `[getChildByName] path=${this.path}, name=${name}, error=${error}`, + ); + return null; + } + } + + async getLength(): Promise { + try { + const result = await this.execPlugin("getLength"); + //console.log(`[getLength] path=${this.path}, length=${result}`); + return result; + } catch (error) { + console.error(`[getLength] path=${this.path}, error=${error}`); + return 0; + } + } + + async getName(): Promise { + try { + const name = await this.execPlugin("getName"); + //console.log(`[getName] path=${this.path}, name=${name}`); + return name; + } catch (error) { + console.error(`[getName] path=${this.path}, error=${error}`); + throw new Error(`Failed to read file name: ${error}`); + } + } + + async getParentFile(): Promise { + try { + const parentPath = await this.execPlugin("getParentFile"); + //console.log(`[getParentFile] path=${this.path}, parentPath=${parentPath}`); + if (parentPath && parentPath !== "") { + return new NativeFileWrapper(parentPath, () => {}); + } + return null; + } catch (error) { + console.error(`[getParentFile] path=${this.path}, error=${error}`); + return null; + } + } + + async isDirectory(): Promise { + try { + const result = await this.execPlugin("isDirectory"); + //console.log(`[isDirectory] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[isDirectory] path=${this.path}, error=${error}`); + return false; + } + } + + async isFile(): Promise { + try { + const result = await this.execPlugin("isFile"); + //console.log(`[isFile] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[isFile] path=${this.path}, error=${error}`); + return false; + } + } + + async isLink(): Promise { + try { + const result = await this.execPlugin("isLink"); + //console.log(`[isLink] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[isLink] path=${this.path}, error=${error}`); + return false; + } + } + + async isNative(): Promise { + try { + const result = await this.execPlugin("isNative"); + //console.log(`[isNative] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[isNative] path=${this.path}, error=${error}`); + return true; + } + } + + async isUnixLike(): Promise { + try { + const result = await this.execPlugin("isUnixLike"); + //console.log(`[isUnixLike] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[isUnixLike] path=${this.path}, error=${error}`); + return true; + } + } + + async listFiles(): Promise { + try { + const paths: string[] = await this.execPlugin("listFiles"); + //console.log(`[listFiles] path=${this.path}, files=${JSON.stringify(paths)}`); + return paths.map((path) => new NativeFileWrapper(path, () => {})); + } catch (error) { + console.error(`[listFiles] path=${this.path}, error=${error}`); + return []; + } + } + + async mkdir(): Promise { + try { + const result = await this.execPlugin("mkdir"); + //console.log(`[mkdir] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[mkdir] path=${this.path}, error=${error}`); + return false; + } + } + + async mkdirs(): Promise { + try { + const result = await this.execPlugin("mkdirs"); + //console.log(`[mkdirs] path=${this.path}, result=${result}`); + return result === 1; + } catch (error) { + console.error(`[mkdirs] path=${this.path}, error=${error}`); + return false; + } + } + + async readText(encoding: string = "UTF-8"): Promise { + try { + const content = await this.execPlugin("readText", [encoding]); + //console.log(`[readText] path=${this.path}, content length=${content?.length}`); + return content; + } catch (error) { + console.error(`[readText] path=${this.path}, error=${error}`); + throw new Error(`Failed to read file: ${error}`); + } + } + + async toUri(): Promise { + try { + //console.log(`[toUri] path=${this.path}, uri=${uri}`); + return await this.execPlugin("toUri"); + } catch (error) { + console.error(`[toUri] path=${this.path}, error=${error}`); + return `file://${this.path}`; + } + } + + async writeText(text: string, encoding: string = "UTF-8"): Promise { + try { + await this.execPlugin("writeText", [text, encoding]); + //console.log(`[writeText] path=${this.path}, text length=${text.length}`); + } catch (error) { + console.error(`[writeText] path=${this.path}, error=${error}`); + throw new Error(`Failed to write file: ${error}`); + } + } + + getPath(): string { + //console.log(`[getPath] returning path=${this.path}`); + return this.path!!; + } +} diff --git a/src/fileSystem/SAFDocumentFile.ts b/src/fileSystem/SAFDocumentFile.ts new file mode 100644 index 000000000..3797dab29 --- /dev/null +++ b/src/fileSystem/SAFDocumentFile.ts @@ -0,0 +1,300 @@ +// @ts-ignore +import loader from "dialogs/loader"; +// @ts-ignore +import { decode, encode } from "utils/encodings"; +// @ts-ignore +import helpers from "utils/helpers"; +// @ts-ignore +import Url from "utils/Url"; + +import { FileObject } from "./fileObject"; + +declare const sdcard: any; +declare var cordova: any; + +//alternative for externalFs.js +export class SAFDocumentFile implements FileObject { + constructor(private uri: string) {} + + private execPlugin(action: string, args: any[] = []): Promise { + return new Promise((resolve, reject) => { + cordova.exec( + (result: any) => { + //console.log(`[NativeFileWrapper] execPlugin success: action=${action}, result=${JSON.stringify(result)}`); + resolve(result); + }, + (error: any) => { + console.error( + `[SAFDocumentFile] execPlugin error: action=${action}, error=${JSON.stringify(error)}`, + ); + reject(error); + }, + "documentFile", + action, + [this.uri, ...args], + ); + }); + } + + async isMyChild(fileObject: FileObject): Promise { + if (!(fileObject instanceof SAFDocumentFile)) { + console.log(`[isMyChild] Not an SAFDocumentFile`); + return false; + } + + try { + console.log( + `[isMyChild] Checking if ${fileObject.uri} is a child of ${this.uri}`, + ); + const result = await this.execPlugin("isMyChild", [fileObject.uri]); + + // result will be 1 or 0 (from the native plugin) + const isChild = result === 1 || result === true; + + console.log(`[isMyChild] Result from native =`, isChild); + return isChild; + } catch (err) { + console.error(`[isMyChild] Error:`, err); + return false; + } + } + + async canRead(): Promise { + const stat = await this.stat(); + return !!stat.canRead; + } + + async canWrite(): Promise { + const stat = await this.stat(); + return !!stat.canWrite; + } + + async childByNameExists(name: string): Promise { + const children = await this.listFiles(); + return children.some((c) => c.getName().then((n) => n === name)); + } + + async createNewFile(): Promise { + try { + await this.writeText(""); + return true; + } catch { + return false; + } + } + + async delete(): Promise { + return new Promise((resolve, reject) => { + sdcard.delete(this.uri, () => resolve(true), reject); + }); + } + + async exists(): Promise { + try { + await this.stat(); + return true; + } catch { + return false; + } + } + + async getChildByName(name: string): Promise { + const children = await this.listFiles(); + for (const child of children) { + if ((await child.getName()) === name) return child; + } + return null; + } + + async getLength(): Promise { + const stat = await this.stat(); + return stat.size ?? 0; + } + + private removeSuffix(str: string, suffix: string) { + return str.endsWith(suffix) ? str.slice(0, -suffix.length) : str; + } + + async getName(): Promise { + const parts = this.removeSuffix(this.uri, "/").split("/"); + return parts[parts.length - 1] || ""; + } + + async getParentFile(): Promise { + //fixme + if (!this.uri) { + return null; + } + + try { + const result = await this.execPlugin("getParentFile"); + return new SAFDocumentFile(result); + } catch (e) { + return null; + } + } + + async isDirectory(): Promise { + const stat = await this.stat(); + return stat.isDirectory === true; + } + + async isFile(): Promise { + const stat = await this.stat(); + return stat.isFile === true; + } + + async isLink(): Promise { + return false; + } + + async isNative(): Promise { + return true; + } + + async isUnixLike(): Promise { + return false; + } + + async listFiles(): Promise { + const uri = await this.formatUri(this.uri); + return new Promise((resolve, reject) => { + sdcard.listDir( + uri, + (entries: any[]) => { + const files = entries.map((e) => new SAFDocumentFile(e.url || e.uri)); + resolve(files); + }, + reject, + ); + }); + } + + async mkdir(): Promise { + const parent = await this.getParentFile(); + if (!parent) return false; + return new Promise((resolve, reject) => { + // @ts-ignore + sdcard.createDir(parent.uri, this.getName(), () => resolve(true), reject); + }); + } + + async mkdirs(): Promise { + // Simplified version that only creates one level + return this.mkdir(); + } + + async readText(encoding = "utf8"): Promise { + const uri = await this.formatUri(this.uri); + return new Promise((resolve, reject) => { + sdcard.read( + uri, + async (data: ArrayBuffer) => { + const text = await decode(data, encoding); + resolve(text); + }, + reject, + ); + }); + } + + async writeText(text: string, encoding = "utf8"): Promise { + const encoded = await encode(text, encoding); + return new Promise((resolve, reject) => { + sdcard.write(this.uri, encoded, resolve, reject); + }); + } + + async toUri(): Promise { + return this.uri; + } + + // ---- Extra helpers translated from externalFs ---- + + private async formatUri(uri: string): Promise { + return new Promise((resolve, reject) => { + sdcard.formatUri(uri, resolve, reject); + }); + } + + private async stat(): Promise { + const storageList = helpers.parseJSON(localStorage.getItem("storageList")); + + if (Array.isArray(storageList)) { + const storage = storageList.find((s) => s.uri === this.uri); + if (storage) { + const stat = { + size: 0, + name: storage.name, + type: "dir", + canRead: true, + canWrite: true, + modifiedDate: new Date(), + isDirectory: true, + isFile: false, + url: this.uri, + }; + + helpers.defineDeprecatedProperty( + stat, + "uri", + function () { + // @ts-ignore + return this.url; + }, + function (value: any) { + // @ts-ignore + this.url = value; + }, + ); + + return stat; + } + } + + const uri = await this.formatUri(this.uri); + return new Promise((resolve, reject) => { + sdcard.stats( + uri, + (stats: any) => { + helpers.defineDeprecatedProperty( + stats, + "uri", + function () { + // @ts-ignore + return this.url; + }, + function (val: any) { + // @ts-ignore + this.url = val; + }, + ); + resolve(stats); + }, + reject, + ); + }); + } + + // ---- Optional static helpers for mount management ---- + + static async listStorages(): Promise { + return new Promise((resolve, reject) => { + sdcard.listStorages(resolve, reject); + }); + } + + static async getStorageAccessPermission( + uuid: string, + name: string, + ): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => loader.destroy(), 100); + sdcard.getStorageAccessPermission(uuid, resolve, reject); + }); + } + + static test(url: string): boolean { + return /^content:/.test(url); + } +} diff --git a/src/fileSystem/fileObject.ts b/src/fileSystem/fileObject.ts new file mode 100644 index 000000000..d28285898 --- /dev/null +++ b/src/fileSystem/fileObject.ts @@ -0,0 +1,143 @@ +/** + * Represents a virtual file or directory that can be backed by any type of storage, + * such as a local filesystem, remote server, or sandboxed virtual environment. + */ + +export interface FileObject { + /** + * Lists all files and directories within this directory. + * @returns A promise resolving to an array of child `FileObject`s. + * @throws If the current object is not a directory. + */ + listFiles(): Promise; + + /** + * Checks if this object represents a directory. + * @returns A promise resolving to `true` if it's a directory, otherwise `false`. + */ + isDirectory(): Promise; + + /** + * Checks if this object represents a regular file. + * @returns A promise resolving to `true` if it's a file, otherwise `false`. + */ + isFile(): Promise; + + /** + * Checks if this object represents a symbolic link or alias. + * @returns A promise resolving to `true` if it's a link, otherwise `false`. + */ + isLink(): Promise; + + /** + * Gets the name of this file or directory (not the full path). + * @returns The name as a string. + */ + getName(): Promise; + + /** + * Checks whether the file or directory is readable. + * @returns A promise resolving to `true` if it can be read, otherwise `false`. + */ + canRead(): Promise; + + /** + * Checks whether the file or directory is writable. + * @returns A promise resolving to `true` if it can be written to, otherwise `false`. + */ + canWrite(): Promise; + + /** + * Determines if this file is backed by the native filesystem and + * can be accessed using `java.io.File` or similar APIs. + * @returns A promise resolving to `true` if it’s a native file. + */ + isNative(): Promise; + + /** + * Determines whether the file path uses a Unix-style format (forward slashes, starting with `/`). + * @returns A promise resolving to `true` if the path is Unix-like. + */ + isUnixLike(): Promise; + + /** + * Gets the file size in bytes. + * @returns A promise resolving to the file’s size. + */ + getLength(): Promise; + + /** + * Checks whether this file or directory exists. + * @returns A promise resolving to `true` if it exists, otherwise `false`. + */ + exists(): Promise; + + /** + * Creates a new empty file at this path. + * @returns A promise resolving to `true` if the file was created, `false` if it already exists. + */ + createNewFile(): Promise; + + /** + * Creates this directory (non-recursively). + * Fails if the parent directory does not exist. + * @returns A promise resolving to `true` if created successfully. + */ + mkdir(): Promise; + + /** + * Creates this directory and all missing parent directories if necessary. + * @returns A promise resolving to `true` if created successfully. + */ + mkdirs(): Promise; + + /** + * Writes text content to this file. + * @param text The text to write. + * @param encoding Optional text encoding (e.g., `"utf-8"`). Defaults to UTF-8. + */ + writeText(text: string, encoding?: string): Promise; + + /** + * Reads the entire content of this file as text. + * @param encoding Optional text encoding (e.g., `"utf-8"`). Defaults to UTF-8. + * @returns A promise resolving to the file’s text content. + */ + readText(encoding?: string): Promise; + + /** + * Deletes this file or directory. + * @returns A promise resolving to `true` if deleted successfully, otherwise `false`. + */ + delete(): Promise; + + /** + * Returns a URI representation of this file (e.g., `file://`, `content://`, or custom scheme). + * @returns A promise resolving to the file’s URI string. + */ + toUri(): Promise; + + /** + * Checks whether a child with the given name exists inside this directory. + * @param name The name of the child file or directory. + * @returns A promise resolving to `true` if it exists, `false` if not, or `null` if unknown. + */ + childByNameExists(name: string): Promise; + + /** + * Gets a `FileObject` representing a child entry with the given name. + * The child may or may not exist yet. + * @param name The name of the child. + * @returns A promise resolving to a `FileObject`, or `null` if the child is impossible + */ + getChildByName(name: string): Promise; + + isMyChild(fileObject: FileObject): Promise; + + /** + * Returns the parent directory of this file. + * @returns A promise resolving to the parent `FileObject`, or `null` if there’s no parent. + * @throws If unable to determine the parent. + */ + getParentFile(): Promise; +} diff --git a/src/lib/Log.ts b/src/lib/Log.ts new file mode 100644 index 000000000..08160c457 --- /dev/null +++ b/src/lib/Log.ts @@ -0,0 +1,40 @@ +/** + * Android.util.Log + */ +export class Log { + constructor(private tag: string) {} + + private format(level: string, message: any): string { + const time = new Date().toISOString(); + return `[${time}] [${this.tag}] [${level}] ${message}`; + } + + i(message: any, ...args: any[]): void { + console.log(`%c${this.format("INFO", message)}`, "color: #00aaff", ...args); + } + + w(message: any, ...args: any[]): void { + console.warn( + `%c${this.format("WARN", message)}`, + "color: #ffaa00", + ...args, + ); + } + + e(message: any, ...args: any[]): void { + console.error( + `%c${this.format("ERROR", message)}`, + "color: #ff4444", + ...args, + ); + } + + d(message: any, ...args: any[]): void { + //TODO: Only show debug messages in debug mode + console.log( + `%c${this.format("DEBUG", message)}`, + "color: #999999", + ...args, + ); + } +} diff --git a/src/lib/acode.js b/src/lib/acode.js index 02ea35172..e218fe7e0 100644 --- a/src/lib/acode.js +++ b/src/lib/acode.js @@ -43,7 +43,11 @@ import encodings, { decode, encode } from "utils/encodings"; import helpers from "utils/helpers"; import KeyboardEvent from "utils/keyboardEvent"; import Url from "utils/Url"; +import { NativeFileWrapper } from "../fileSystem/NativeFileWrapper"; +import { SAFDocumentFile } from "../fileSystem/SAFDocumentFile"; import constants from "./constants"; +import { FileServer } from "./fileServer"; +import { Log } from "./Log"; const { Fold } = ace.require("ace/edit_session/fold"); const { Range } = ace.require("ace/range"); @@ -122,6 +126,10 @@ export default class Acode { }, }; + this.define("nativeFile", NativeFileWrapper); + this.define("SAFDocumentFile", SAFDocumentFile); + this.define("fileServer", FileServer); + this.define("log", Log); this.define("Url", Url); this.define("page", Page); this.define("Color", Color); diff --git a/src/lib/run.js b/src/lib/run.js index 709217531..3c39fce1e 100644 --- a/src/lib/run.js +++ b/src/lib/run.js @@ -14,6 +14,7 @@ import helpers from "utils/helpers"; import Url from "utils/Url"; import $_console from "views/console.hbs"; import $_markdown from "views/markdown.hbs"; +import { SAFDocumentFile } from "../fileSystem/SAFDocumentFile"; import constants from "./constants"; import EditorFile from "./editorFile"; import openFolder, { addedFolder } from "./openFolder"; @@ -194,7 +195,7 @@ async function run( console.log(req); if (!reqPath || (reqPath.endsWith("/") && reqPath.length === 1)) { - reqPath = getRelativePath(); + reqPath = await getRelativePath(); } console.log(`XREQPATH1 ${reqPath}`); @@ -293,77 +294,101 @@ async function run( rootFolder = rootFolder.replace(/\/+$/, ""); // remove trailing slash reqPath = reqPath.replace(/^\/+/, ""); // remove leading slash - const rootParts = rootFolder.split("/"); - const pathParts = reqPath.split("/"); + // Use SAFDocumentFile for Android content URIs + let fullPath; - if (pathParts[0] === rootParts[rootParts.length - 1]) { - pathParts.shift(); - } + if (rootFolder.startsWith("content://")) { + try { + // For SAF URIs, reconstruct the path properly + const rootFile = new SAFDocumentFile(rootFolder); + + // Split the request path to navigate through directories + const pathParts = reqPath.split("/").filter((p) => p); + + // Start from root and navigate to the requested file + let currentFile = rootFile; + for (const part of pathParts) { + const child = await currentFile.getChildByName(part); + if (!child) { + console.error(`Could not find child: ${part}`); + error(reqId); + return; + } + currentFile = child; + } - function removePrefix(str, prefix) { - if (str.startsWith(prefix)) { - return str.slice(prefix.length); + fullPath = await currentFile.toUri(); + console.log(`SAF Full PATH: ${fullPath}`); + } catch (err) { + console.error("Error navigating SAF path:", err); + error(reqId); + return; } - return str; - } - - function findOverlap(a, b) { - // Start with the smallest possible overlap (1 character) and increase - let maxOverlap = ""; + } else { + // Original path handling for non-SAF URIs + const rootParts = rootFolder.split("/"); + const pathParts = reqPath.split("/"); - // Check all possible overlapping lengths - for (let i = 1; i <= Math.min(a.length, b.length); i++) { - // Get the ending substring of a with length i - const endOfA = a.slice(-i); - // Get the starting substring of b with length i - const startOfB = b.slice(0, i); + if (pathParts[0] === rootParts[rootParts.length - 1]) { + pathParts.shift(); + } - // If they match, we have a potential overlap - if (endOfA === startOfB) { - maxOverlap = endOfA; + function removePrefix(str, prefix) { + if (str.startsWith(prefix)) { + return str.slice(prefix.length); } + return str; } - return maxOverlap; - } + function findOverlap(a, b) { + let maxOverlap = ""; + for (let i = 1; i <= Math.min(a.length, b.length); i++) { + const endOfA = a.slice(-i); + const startOfB = b.slice(0, i); + if (endOfA === startOfB) { + maxOverlap = endOfA; + } + } + return maxOverlap; + } - console.log(`RootFolder ${rootFolder}`); - console.log(`PARTS ${pathParts.join("/")}`); + console.log(`RootFolder ${rootFolder}`); + console.log(`PARTS ${pathParts.join("/")}`); - let fullPath; - // Skip overlap detection for GitHub URIs as it causes path corruption - if (rootFolder.startsWith("gh://")) { - fullPath = Url.join(rootFolder, pathParts.join("/")); - } else { - const overlap = findOverlap(rootFolder, pathParts.join("/")); - if (overlap !== "") { - fullPath = Url.join( - rootFolder, - removePrefix(pathParts.join("/"), overlap), - ); - } else { + // Skip overlap detection for GitHub URIs as it causes path corruption + if (rootFolder.startsWith("gh://")) { fullPath = Url.join(rootFolder, pathParts.join("/")); + } else { + const overlap = findOverlap(rootFolder, pathParts.join("/")); + if (overlap !== "") { + fullPath = Url.join( + rootFolder, + removePrefix(pathParts.join("/"), overlap), + ); + } else { + fullPath = Url.join(rootFolder, pathParts.join("/")); + } } - } - console.log(`Full PATH ${fullPath}`); + console.log(`Full PATH ${fullPath}`); - const urlFile = fsOperation(fullPath); + const urlFile = fsOperation(fullPath); - // Skip stat check for GitHub URIs as they are handled differently - if (!fullPath.startsWith("gh://")) { - const stats = await urlFile.stat(); + // Skip stat check for GitHub URIs as they are handled differently + if (!fullPath.startsWith("gh://")) { + const stats = await urlFile.stat(); - if (!stats.exists) { - error(reqId); - return; - } + if (!stats.exists) { + error(reqId); + return; + } - if (!stats.isFile) { - if (fullPath.endsWith("/")) { - fullPath += "index.html"; - } else { - fullPath += "/index.html"; + if (!stats.isFile) { + if (fullPath.endsWith("/")) { + fullPath += "index.html"; + } else { + fullPath += "/index.html"; + } } } } @@ -632,32 +657,77 @@ async function run( return uri; } - function getRelativePath() { - // Get the project url + async function getRelativePath() { const projectFolder = addedFolder[0]; - - // FIXED: Better root folder determination for Termux URIs let rootFolder = pathName; - // Special handling for Termux URIs - extract the actual root from the URI structure + // Use SAFDocumentFile for Android content URIs if ( activeFile && activeFile.uri && - activeFile.uri.includes("com.termux.documents") && - activeFile.uri.includes("tree/") + activeFile.uri.startsWith("content://") ) { - // Extract the tree part and decode it to get the actual root path - const treeMatch = activeFile.uri.match(/tree\/([^:]+)/); - if (treeMatch) { - try { - const decodedRoot = decodeURIComponent(treeMatch[1]); - rootFolder = decodedRoot; - console.log(`DEBUG - Termux root folder set to: ${rootFolder}`); - } catch (e) { - console.error("Error decoding Termux root:", e); + try { + console.log(`DEBUG - Using SAFDocumentFile for URI: ${activeFile.uri}`); + + const rootFile = new SAFDocumentFile(rootFolder); + const currentFile = new SAFDocumentFile(Url.join(pathName, filename)); + + // Get clean filename + const fileName = await currentFile.getName(); + console.log(`DEBUG - SAF fileName: ${fileName}`); + + // Check if current file is a child of root + const isChild = await rootFile.isMyChild(currentFile); + console.log(`DEBUG - Is child of root: ${isChild}`); + + if (isChild) { + // Build relative path by walking up to root + const relativeParts = []; + let current = currentFile; + + // Add current file name first + relativeParts.unshift(fileName); + + // Walk up the tree until we reach root + let parent = await current.getParentFile(); + while (parent) { + const parentUri = await parent.toUri(); + console.log(`DEBUG - Checking parent URI: ${parentUri}`); + + // Normalize URIs for comparison + const normalizedParentUri = parentUri.replace(/\/+$/, ""); + const normalizedRootFolder = rootFolder.replace(/\/+$/, ""); + + if (normalizedParentUri === normalizedRootFolder) { + console.log(`DEBUG - Reached root folder`); + break; + } + + const parentName = await parent.getName(); + console.log(`DEBUG - Adding parent to path: ${parentName}`); + relativeParts.unshift(parentName); + + current = parent; + parent = await current.getParentFile(); + } + + const relativePath = relativeParts.join("/"); + console.log(`DEBUG - Final SAF relative path: ${relativePath}`); + return relativePath; + } else { + // If not a child, just return the filename + console.log(`DEBUG - Not a child, returning filename: ${fileName}`); + return fileName; } + } catch (e) { + console.error("Error using SAFDocumentFile:", e); + // Fall through to existing logic } - } else if ( + } + + // Original logic for non-SAF URIs + if ( projectFolder !== undefined && pathName && pathName.includes(projectFolder.url) @@ -665,10 +735,8 @@ async function run( rootFolder = projectFolder.url; } - //make the uri absolute if necessary rootFolder = makeUriAbsoluteIfNeeded(rootFolder); - // Parent of the file let filePath = pathName; if (rootFolder.startsWith("ftp:") || rootFolder.startsWith("sftp:")) { @@ -677,101 +745,15 @@ async function run( } } - //remove the query string if present this is needs to be removed because the url is not valid if (filePath.startsWith("ftp:") || rootFolder.startsWith("sftp:")) { if (filePath.includes("?")) { filePath = filePath.split("?")[0]; } } - // Create full file path let temp = Url.join(filePath, filename); - // Special handling for Termux URIs - if (temp.includes("com.termux.documents") && temp.includes("::")) { - try { - const [, realPath] = temp.split("::"); - - console.log(`DEBUG - realPath: ${realPath}`); - console.log(`DEBUG - rootFolder: ${rootFolder}`); - - // Ensure rootFolder doesn't have trailing slash for comparison - const normalizedRoot = rootFolder.replace(/\/+$/, ""); - - // Check if realPath starts with rootFolder - if (realPath.startsWith(normalizedRoot)) { - // Remove the rootFolder from the beginning of realPath - let relativePath = realPath.substring(normalizedRoot.length); - - // Remove leading slash if present - relativePath = relativePath.replace(/^\/+/, ""); - - console.log(`DEBUG - relativePath: ${relativePath}`); - - if (relativePath) { - return relativePath; - } - } - } catch (e) { - console.error("Error handling Termux URI:", e); - } - } - - // Handle other content:// URIs - if (temp.includes("content://") && temp.includes("::")) { - try { - // Get the part after :: which contains the actual file path - const afterDoubleColon = temp.split("::")[1]; - - if (afterDoubleColon) { - // Extract the rootFolder's content path if it has :: - let rootFolderPath = rootFolder; - if (rootFolder.includes("::")) { - rootFolderPath = rootFolder.split("::")[1]; - } - - // If rootFolder doesn't have ::, try to extract the last part of the path - if (!rootFolderPath.includes("::")) { - const rootParts = rootFolder.split("/"); - const lastPart = rootParts[rootParts.length - 1]; - - // Check if the lastPart is encoded - if (lastPart.includes("%3A")) { - // Try to decode it - try { - const decoded = decodeURIComponent(lastPart); - rootFolderPath = decoded; - } catch (e) { - console.error("Error decoding URI component:", e); - rootFolderPath = lastPart; - } - } else { - rootFolderPath = lastPart; - } - } - - // Use direct string replacement instead of path component comparison - const normalizedRoot = rootFolderPath.replace(/\/+$/, ""); - if (afterDoubleColon.startsWith(normalizedRoot)) { - let relativePath = afterDoubleColon.substring( - normalizedRoot.length, - ); - // Remove leading slash if present - relativePath = relativePath.replace(/^\/+/, ""); - - if (relativePath) { - return relativePath; - } - } - } - } catch (e) { - console.error("Error parsing content URI:", e); - } - } - - // For regular paths or if content:// URI parsing failed // Try to find a common prefix between rootFolder and temp - // and remove it from temp try { const rootParts = rootFolder.split("/"); const tempParts = temp.split("/"); @@ -804,12 +786,13 @@ async function run( /** * Opens the preview in browser */ - function openBrowser() { + async function openBrowser() { let url = ""; if (pathName === null && !activeFile.location) { url = `http://localhost:${port}/__unsaved_file__`; } else { - url = `http://localhost:${port}/${getRelativePath()}`; + const relativePath = await getRelativePath(); + url = `http://localhost:${port}${relativePath}`; } if (target === "browser") { diff --git a/src/plugins/file/package.json b/src/plugins/file/package.json new file mode 100644 index 000000000..1692f62b4 --- /dev/null +++ b/src/plugins/file/package.json @@ -0,0 +1,17 @@ +{ + "name": "file", + "version": "1.0.0", + "description": "Full api of java.io.File", + "cordova": { + "id": "com.foxdebug.acode.rk.file", + "platforms": [ + "android" + ] + }, + "keywords": [ + "ecosystem:cordova", + "cordova-android" + ], + "author": "@RohitKushvaha01", + "license": "MIT" +} diff --git a/src/plugins/file/plugin.xml b/src/plugins/file/plugin.xml new file mode 100644 index 000000000..932c33acc --- /dev/null +++ b/src/plugins/file/plugin.xml @@ -0,0 +1,19 @@ + + + file + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/plugins/file/src/android/documentFile.java b/src/plugins/file/src/android/documentFile.java new file mode 100644 index 000000000..a936d7598 --- /dev/null +++ b/src/plugins/file/src/android/documentFile.java @@ -0,0 +1,284 @@ +package com.foxdebug.acode.rk.file; + +import org.apache.cordova.*; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.net.Uri; +import android.content.Context; +import android.util.Log; + +import androidx.documentfile.provider.DocumentFile; + +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import android.provider.DocumentsContract; + + +public class documentFile extends CordovaPlugin { + + private Context getContext() { + return cordova.getContext(); + } + + @Override + public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { + try { + switch (action) { + case "exists": return handleExists(args, callbackContext); + case "isFile": return handleIsFile(args, callbackContext); + case "isDirectory": return handleIsDirectory(args, callbackContext); + case "getLength": return handleGetLength(args, callbackContext); + case "getName": return handleGetName(args, callbackContext); + case "getParentFile": return handleGetParentFile(args, callbackContext); + case "listFiles": return handleListFiles(args, callbackContext); + case "createNewFile": return handleCreateNewFile(args, callbackContext); + case "mkdir": return handleMkdir(args, callbackContext); + case "delete": return handleDelete(args, callbackContext); + case "readText": return handleReadText(args, callbackContext); + case "writeText": return handleWriteText(args, callbackContext); + case "canRead": return handleCanRead(args, callbackContext); + case "canWrite": return handleCanWrite(args, callbackContext); + case "childByNameExists": return handleChildByNameExists(args, callbackContext); + case "getChildByName": return handleGetChildByName(args, callbackContext); + case "isMyChild": return handleIsMyChild(args, callbackContext); + case "toUri": return handleToUri(args, callbackContext); + default: return false; + } + } catch (Exception e) { + callbackContext.error("Error: " + e.getMessage()); + return true; + } + } + + private boolean handleIsMyChild(JSONArray args, CallbackContext cb) throws JSONException { + String parentUriStr = args.getString(0); + String childUriStr = args.getString(1); + + try { + Uri parentUri = Uri.parse(parentUriStr); + Uri childUri = Uri.parse(childUriStr); + + boolean result = false; + + // SAF-safe check + if (DocumentsContract.isDocumentUri(getContext(), childUri)) { + try { + result = DocumentsContract.isChildDocument( + getContext().getContentResolver(), + parentUri, + childUri + ); + } catch (Exception e) { + // If that fails, fallback to ID-based check + result = isChildByDocIdFallback(parentUri, childUri); + } + } else { + // Non-SAF fallback: compare normalized paths + String p = parentUri.getPath(); + String c = childUri.getPath(); + if (p != null && c != null && c.startsWith(p)) { + result = true; + } + } + + cb.success(result ? 1 : 0); + } catch (Exception e) { + cb.error("Error: " + e.getMessage()); + } + return true; + } + + /** + * Fallback if DocumentsContract.isChildDocument() fails. + * Checks if the child’s document ID starts with parent’s document ID. + */ + private boolean isChildByDocIdFallback(Uri parentUri, Uri childUri) { + String parentId = DocumentsContract.getDocumentId(parentUri); + String childId = DocumentsContract.getDocumentId(childUri); + return childId != null && parentId != null && childId.startsWith(parentId + "%2F"); + } + + + private DocumentFile fromUri(String uriStr) { + try { + // Decode once if double-encoded + if (uriStr.contains("%25")) { + uriStr = Uri.decode(uriStr); + } + + Uri uri = Uri.parse(uriStr); + + DocumentFile file; + if (DocumentsContract.isTreeUri(uri)) { + file = DocumentFile.fromTreeUri(getContext(), uri); + } else { + file = DocumentFile.fromSingleUri(getContext(), uri); + } + + return file; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + + private boolean handleExists(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null && f.exists() ? 1 : 0); + return true; + } + + private boolean handleIsFile(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null && f.isFile() ? 1 : 0); + return true; + } + + private boolean handleIsDirectory(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null && f.isDirectory() ? 1 : 0); + return true; + } + + private boolean handleGetLength(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null ? (int) f.length() : 0); + return true; + } + + private boolean handleGetName(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null ? f.getName() : ""); + return true; + } + + private boolean handleGetParentFile(JSONArray args, CallbackContext cb) throws JSONException { + android.util.Log.d("DocumentFile.java",args.getString(0)); + DocumentFile f = fromUri(args.getString(0)); + DocumentFile parent = f != null ? f.getParentFile() : null; + cb.success(parent != null ? parent.getUri().toString() : ""); + return true; + } + + private boolean handleListFiles(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + if (f == null || !f.isDirectory()) { + cb.error("Not a directory or invalid URI"); + return true; + } + + JSONArray result = new JSONArray(); + for (DocumentFile file : f.listFiles()) { + result.put(file.getUri().toString()); + } + cb.success(result); + return true; + } + + private boolean handleCreateNewFile(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile parent = fromUri(args.getString(0)); + String mime = args.optString(1, "text/plain"); + String name = args.optString(2, "newfile.txt"); + + if (parent != null && parent.isDirectory()) { + DocumentFile newFile = parent.createFile(mime, name); + cb.success(newFile != null ? newFile.getUri().toString() : ""); + } else { + cb.error("Invalid parent directory"); + } + return true; + } + + private boolean handleMkdir(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile parent = fromUri(args.getString(0)); + String name = args.optString(1, "NewFolder"); + + if (parent != null && parent.isDirectory()) { + DocumentFile newDir = parent.createDirectory(name); + cb.success(newDir != null ? 1 : 0); + } else { + cb.error("Invalid parent directory"); + } + return true; + } + + private boolean handleDelete(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null && f.delete() ? 1 : 0); + return true; + } + + private boolean handleReadText(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + String encoding = args.length() > 1 ? args.getString(1) : "UTF-8"; + + try (InputStream in = getContext().getContentResolver().openInputStream(f.getUri())) { + byte[] data = in.readAllBytes(); + cb.success(new String(data, encoding)); + } catch (Exception e) { + cb.error(e.getMessage()); + } + return true; + } + + private boolean handleWriteText(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + String text = args.getString(1); + String encoding = args.length() > 2 ? args.getString(2) : "UTF-8"; + + try (OutputStream out = getContext().getContentResolver().openOutputStream(f.getUri(), "wt")) { + out.write(text.getBytes(encoding)); + out.flush(); + cb.success(1); + } catch (Exception e) { + cb.error(e.getMessage()); + } + return true; + } + + private boolean handleCanRead(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null && f.canRead() ? 1 : 0); + return true; + } + + private boolean handleCanWrite(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null && f.canWrite() ? 1 : 0); + return true; + } + + private boolean handleChildByNameExists(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile parent = fromUri(args.getString(0)); + String childName = args.getString(1); + if (parent == null || !parent.isDirectory()) { + cb.success(0); + return true; + } + DocumentFile child = parent.findFile(childName); + cb.success(child != null && child.exists() ? 1 : 0); + return true; + } + + private boolean handleGetChildByName(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile parent = fromUri(args.getString(0)); + String childName = args.getString(1); + if (parent == null || !parent.isDirectory()) { + cb.success(""); + return true; + } + DocumentFile child = parent.findFile(childName); + cb.success(child != null ? child.getUri().toString() : ""); + return true; + } + + private boolean handleToUri(JSONArray args, CallbackContext cb) throws JSONException { + DocumentFile f = fromUri(args.getString(0)); + cb.success(f != null ? f.getUri().toString() : ""); + return true; + } +} diff --git a/src/plugins/file/src/android/nativeFile.java b/src/plugins/file/src/android/nativeFile.java new file mode 100644 index 000000000..bbe8340c7 --- /dev/null +++ b/src/plugins/file/src/android/nativeFile.java @@ -0,0 +1,281 @@ +package com.foxdebug.acode.rk.file; + +import org.apache.cordova.*; +import org.json.JSONArray; +import org.json.JSONException; + +import android.util.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class nativeFile extends CordovaPlugin { + + @Override + public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { + try { + switch (action) { + case "exists": + return handleExists(args, callbackContext); + + case "isFile": + return handleIsFile(args, callbackContext); + + case "isDirectory": + return handleIsDirectory(args, callbackContext); + + case "getLength": + return handleGetLength(args, callbackContext); + + case "getName": + return handleGetName(args, callbackContext); + + case "getParentFile": + return handleGetParentFile(args, callbackContext); + + case "listFiles": + return handleListFiles(args, callbackContext); + + case "createNewFile": + return handleCreateNewFile(args, callbackContext); + + case "mkdir": + return handleMkdir(args, callbackContext); + + case "mkdirs": + return handleMkdirs(args, callbackContext); + + case "delete": + return handleDelete(args, callbackContext); + + case "readText": + return handleReadText(args, callbackContext); + + case "writeText": + return handleWriteText(args, callbackContext); + + case "canRead": + return handleCanRead(args, callbackContext); + + case "canWrite": + return handleCanWrite(args, callbackContext); + + case "childByNameExists": + return handleChildByNameExists(args, callbackContext); + + case "getChildByName": + return handleGetChildByName(args, callbackContext); + + case "isLink": + return handleIsLink(args, callbackContext); + + case "toUri": + return handleToUri(args, callbackContext); + + default: + return false; + } + } catch (Exception e) { + callbackContext.error("Error: " + e.getMessage()); + return true; + } + } + + private boolean handleExists(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.exists() ? 1 : 0); + return true; + } + + private boolean handleIsFile(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.isFile() ? 1 : 0); + return true; + } + + private boolean handleIsDirectory(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.isDirectory() ? 1 : 0); + return true; + } + + private boolean handleGetLength(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success((int) f.length()); + return true; + } + + private boolean handleGetName(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.getName()); + return true; + } + + private boolean handleGetParentFile(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + File parent = f.getParentFile(); + cb.success(parent != null ? parent.getAbsolutePath() : ""); + return true; + } + + private boolean handleListFiles(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + if (!f.isDirectory()) { + cb.error("Not a directory"); + return true; + } + File[] files = f.listFiles(); + JSONArray result = new JSONArray(); + if (files != null) { + for (File file : files) { + result.put(file.getAbsolutePath()); + } + } + cb.success(result); + return true; + } + + private boolean handleCreateNewFile(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + try { + boolean created = f.createNewFile(); + cb.success(created ? 1 : 0); + } catch (IOException e) { + cb.error(e.getMessage()); + } + return true; + } + + private boolean handleMkdir(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.mkdir() ? 1 : 0); + return true; + } + + private boolean handleMkdirs(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.mkdirs() ? 1 : 0); + return true; + } + + private boolean handleDelete(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.delete() ? 1 : 0); + return true; + } + + private boolean handleReadText(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + String encoding = args.length() > 1 ? args.getString(1) : "UTF-8"; + try (FileInputStream fis = new FileInputStream(f)) { + byte[] data = new byte[(int) f.length()]; + fis.read(data); + String text = new String(data, encoding); + cb.success(text); + } catch (Exception e) { + cb.error(e.getMessage()); + } + return true; + } + + private boolean handleWriteText(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + String text = args.getString(1); + String encoding = args.length() > 2 ? args.getString(2) : "UTF-8"; + try (FileOutputStream fos = new FileOutputStream(f)) { + fos.write(text.getBytes(encoding)); + fos.flush(); + cb.success(1); + } catch (Exception e) { + cb.error(e.getMessage()); + } + return true; + } + + // New methods below + + private boolean handleCanRead(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.canRead() ? 1 : 0); + return true; + } + + private boolean handleCanWrite(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + cb.success(f.canWrite() ? 1 : 0); + return true; + } + + private boolean handleChildByNameExists(JSONArray args, CallbackContext cb) throws JSONException { + File parent = new File(args.getString(0)); + String childName = args.getString(1); + + if (!parent.isDirectory()) { + cb.success(0); + return true; + } + + File child = new File(parent, childName); + cb.success(child.exists() ? 1 : 0); + return true; + } + + private boolean handleGetChildByName(JSONArray args, CallbackContext cb) throws JSONException { + File parent = new File(args.getString(0)); + String childName = args.getString(1); + + if (!parent.isDirectory()) { + cb.success(""); + return true; + } + + File child = new File(parent, childName); + if (child.exists()) { + cb.success(child.getAbsolutePath()); + } else { + cb.success(""); + } + return true; + } + + private boolean handleIsLink(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + + // For API 26+, use Files.isSymbolicLink + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + try { + Path path = Paths.get(f.getAbsolutePath()); + cb.success(Files.isSymbolicLink(path) ? 1 : 0); + } catch (Exception e) { + cb.success(0); + } + } else { + // Fallback for older Android versions + try { + String canonicalPath = f.getCanonicalPath(); + String absolutePath = f.getAbsolutePath(); + boolean isLink = !canonicalPath.equals(absolutePath); + cb.success(isLink ? 1 : 0); + } catch (IOException e) { + cb.success(0); + } + } + return true; + } + + private boolean handleToUri(JSONArray args, CallbackContext cb) throws JSONException { + File f = new File(args.getString(0)); + try { + String uri = f.toURI().toString(); + cb.success(uri); + } catch (Exception e) { + cb.error(e.getMessage()); + } + return true; + } +} \ No newline at end of file diff --git a/src/plugins/terminal/plugin.xml b/src/plugins/terminal/plugin.xml index fbde6010f..27e78fc14 100644 --- a/src/plugins/terminal/plugin.xml +++ b/src/plugins/terminal/plugin.xml @@ -20,9 +20,9 @@ - - + + @@ -31,25 +31,32 @@ + android:name="com.foxdebug.acode.rk.exec.terminal.AlpineDocumentProvider" + android:authorities="${applicationId}.documents" + android:exported="true" + android:grantUriPermissions="true" + android:icon="@mipmap/ic_launcher" + android:permission="android.permission.MANAGE_DOCUMENTS"> + + + + + + + @@ -57,9 +64,5 @@ - - - - \ No newline at end of file diff --git a/src/plugins/terminal/src/android/Executor.java b/src/plugins/terminal/src/android/Executor.java index 3e0b4e83d..61ebcef03 100644 --- a/src/plugins/terminal/src/android/Executor.java +++ b/src/plugins/terminal/src/android/Executor.java @@ -3,250 +3,91 @@ import org.apache.cordova.*; import org.json.*; -import android.content.ComponentName; +import org.apache.cordova.CordovaInterface; +import org.apache.cordova.CordovaPlugin; +import org.apache.cordova.CordovaWebView; +import org.apache.cordova.CallbackContext; +import org.apache.cordova.PluginResult; + +import org.json.JSONArray; +import org.json.JSONException; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.*; +import java.io.OutputStream; + +import java.util.Map; +import java.util.HashMap; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + import android.content.Context; import android.content.Intent; -import android.content.ServiceConnection; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.Message; -import android.os.Messenger; -import android.os.RemoteException; -import android.util.Log; - -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import android.Manifest; -import android.content.pm.PackageManager; import android.os.Build; +import android.content.pm.PackageManager; -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import android.app.Activity; public class Executor extends CordovaPlugin { - private Messenger serviceMessenger; - private boolean isServiceBound; - private boolean isServiceBinding; // Track if binding is in progress + private final Map processes = new ConcurrentHashMap<>(); + private final Map processInputs = new ConcurrentHashMap<>(); + private final Map processCallbacks = new ConcurrentHashMap<>(); + private Context context; - private Activity activity; - private final Messenger handlerMessenger = new Messenger(new IncomingHandler()); - private CountDownLatch serviceConnectedLatch; - private final java.util.Map callbackContextMap = new java.util.concurrent.ConcurrentHashMap<>(); - - private static final int REQUEST_POST_NOTIFICATIONS = 1001; - - private void askNotificationPermission(Activity context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission( - context, Manifest.permission.POST_NOTIFICATIONS) == - PackageManager.PERMISSION_GRANTED) { - } else if (ActivityCompat.shouldShowRequestPermissionRationale( - context, Manifest.permission.POST_NOTIFICATIONS)) { - ActivityCompat.requestPermissions( - context, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, - REQUEST_POST_NOTIFICATIONS - ); - } else { - ActivityCompat.requestPermissions( - context, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, - REQUEST_POST_NOTIFICATIONS - ); - } - } - } - @Override - public void initialize(CordovaInterface cordova, CordovaWebView webView) { - super.initialize(cordova, webView); - this.context = cordova.getContext(); - this.activity = cordova.getActivity(); - askNotificationPermission(activity); - - // Don't bind service immediately - wait until needed - Log.d("Executor", "Plugin initialized - service will be started when needed"); - } - /** - * Ensure service is bound and ready for communication - * Returns true if service is ready, false if binding failed - */ - private boolean ensureServiceBound(CallbackContext callbackContext) { - // If already bound, return immediately - if (isServiceBound && serviceMessenger != null) { - return true; - } + @Override + public void initialize(CordovaInterface cordova, CordovaWebView webView) { + super.initialize(cordova, webView); + this.context = cordova.getContext(); + + } - // If binding is already in progress, wait for it - if (isServiceBinding) { - try { - if (serviceConnectedLatch != null && - serviceConnectedLatch.await(10, TimeUnit.SECONDS)) { - return isServiceBound; - } else { - callbackContext.error("Service binding timeout"); - return false; - } - } catch (InterruptedException e) { - callbackContext.error("Service binding interrupted: " + e.getMessage()); - return false; + private void checkStopService() { + boolean hasRunning = false; + + for (Process p : processes.values()) { + if (p != null && p.isAlive()) { + hasRunning = true; + break; } } - // Start binding process - Log.d("Executor", "Starting service binding..."); - return bindServiceNow(callbackContext); + if (!hasRunning) { + // Stop foreground service + Intent serviceIntent = new Intent(context, KeepAliveService.class); + context.stopService(serviceIntent); + } } - /** - * Immediately bind to service - */ - private boolean bindServiceNow(CallbackContext callbackContext) { - if (isServiceBinding) { - return false; // Already binding - } + private void startService(){ + Intent serviceIntent = new Intent(this.context, KeepAliveService.class); - isServiceBinding = true; - serviceConnectedLatch = new CountDownLatch(1); - - Intent intent = new Intent(context, TerminalService.class); - - // Start the service first - context.startService(intent); - - // Then bind to it - boolean bindResult = context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE); - - if (!bindResult) { - Log.e("Executor", "Failed to bind to service"); - isServiceBinding = false; - callbackContext.error("Failed to bind to service"); - return false; - } - - // Wait for connection - try { - if (serviceConnectedLatch.await(10, TimeUnit.SECONDS)) { - Log.d("Executor", "Service bound successfully"); - return isServiceBound; - } else { - Log.e("Executor", "Service binding timeout"); - callbackContext.error("Service binding timeout"); - isServiceBinding = false; - return false; - } - } catch (InterruptedException e) { - Log.e("Executor", "Service binding interrupted: " + e.getMessage()); - callbackContext.error("Service binding interrupted: " + e.getMessage()); - isServiceBinding = false; - return false; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // Android 8.0+ needs startForegroundService() + this.context.startForegroundService(serviceIntent); + } else { + // Older versions use startService() + this.context.startService(serviceIntent); } } - private final ServiceConnection serviceConnection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - Log.d("Executor", "Service connected"); - serviceMessenger = new Messenger(service); - isServiceBound = true; - isServiceBinding = false; - if (serviceConnectedLatch != null) { - serviceConnectedLatch.countDown(); - } - } + - @Override - public void onServiceDisconnected(ComponentName name) { - Log.w("Executor", "Service disconnected"); - serviceMessenger = null; - isServiceBound = false; - isServiceBinding = false; - serviceConnectedLatch = new CountDownLatch(1); - } - }; - - private class IncomingHandler extends Handler { - @Override - public void handleMessage(Message msg) { - Bundle bundle = msg.getData(); - String id = bundle.getString("id"); - String action = bundle.getString("action"); - String data = bundle.getString("data"); - - if (action.equals("exec_result")) { - CallbackContext callbackContext = getCallbackContext(id); - if (callbackContext != null) { - if (bundle.getBoolean("isSuccess", false)) { - callbackContext.success(data); - } else { - callbackContext.error(data); - } - cleanupCallback(id); - } - } else { - String pid = id; - CallbackContext callbackContext = getCallbackContext(pid); - - if (callbackContext != null) { - switch (action) { - case "stdout": - case "stderr": - PluginResult result = new PluginResult(PluginResult.Status.OK, action + ":" + data); - result.setKeepCallback(true); - callbackContext.sendPluginResult(result); - break; - case "exit": - cleanupCallback(pid); - callbackContext.success("exit:" + data); - break; - case "isRunning": - callbackContext.success(data); - cleanupCallback(pid); - break; - } - } - } - } - } @Override public boolean execute(String action, JSONArray args, CallbackContext callbackContext) throws JSONException { - // For actions that don't need the service, handle them directly - if (action.equals("loadLibrary")) { - try { - System.load(args.getString(0)); - callbackContext.success("Library loaded successfully."); - } catch (Exception e) { - callbackContext.error("Failed to load library: " + e.getMessage()); - } - return true; - } + switch (action) { + case "start": - if (action.equals("stopService")) { - stopServiceNow(); - callbackContext.success("Service stopped"); - return true; - } + startService(); - // For all other actions, ensure service is bound first - if (!ensureServiceBound(callbackContext)) { - // Error already sent by ensureServiceBound - return false; - } - switch (action) { - case "start": String cmdStart = args.getString(0); String pid = UUID.randomUUID().toString(); - callbackContextMap.put(pid, callbackContext); - startProcess(pid, cmdStart, args.getString(1)); + startProcess(pid, cmdStart,args.getString(1), callbackContext); return true; case "write": String pidWrite = args.getString(0); @@ -258,14 +99,22 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo stopProcess(pidStop, callbackContext); return true; case "exec": - String execId = UUID.randomUUID().toString(); - callbackContextMap.put(execId, callbackContext); - exec(execId, args.getString(0), args.getString(1)); + + startService(); + + + exec(args.getString(0),args.getString(1), callbackContext); return true; case "isRunning": - String pidCheck = args.getString(0); - callbackContextMap.put(pidCheck, callbackContext); - isProcessRunning(pidCheck); + isProcessRunning(args.getString(0), callbackContext); + return true; + case "loadLibrary": + try { + System.load(args.getString(0)); + callbackContext.success("Library loaded successfully."); + } catch (Exception e) { + callbackContext.error("Failed to load library: " + e.getMessage()); + } return true; default: callbackContext.error("Unknown action: " + action); @@ -273,127 +122,194 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo } } - private void stopServiceNow() { - if (isServiceBound) { - try { - context.unbindService(serviceConnection); - Log.d("Executor", "Service unbound"); - } catch (IllegalArgumentException ignored) { - // already unbound - } - isServiceBound = false; - } - isServiceBinding = false; - - Intent intent = new Intent(context, TerminalService.class); - boolean stopped = context.stopService(intent); - Log.d("Executor", "Service stop result: " + stopped); - - serviceMessenger = null; - if (serviceConnectedLatch == null) { - serviceConnectedLatch = new CountDownLatch(1); - } - } + private void exec(String cmd,String alpine, CallbackContext callbackContext) { + cordova.getThreadPool().execute(() -> { + try { + if (cmd != null && !cmd.isEmpty()) { + String xcmd = cmd; + if(alpine.equals("true")){ + xcmd = "source $PREFIX/init-sandbox.sh "+cmd; + } + + ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); + + // Set environment variables + Map env = builder.environment(); + env.put("PREFIX", context.getFilesDir().getAbsolutePath()); + env.put("NATIVE_DIR", context.getApplicationInfo().nativeLibraryDir); + TimeZone tz = TimeZone.getDefault(); + String timezoneId = tz.getID(); + env.put("ANDROID_TZ", timezoneId); + + try { + int target = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).applicationInfo.targetSdkVersion; + env.put("FDROID", String.valueOf(target <= 28)); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } - private void startProcess(String pid, String cmd, String alpine) { - CallbackContext callbackContext = getCallbackContext(pid); - if (callbackContext != null) { - PluginResult result = new PluginResult(PluginResult.Status.OK, pid); - result.setKeepCallback(true); - callbackContext.sendPluginResult(result); - } - Message msg = Message.obtain(null, TerminalService.MSG_START_PROCESS); - msg.replyTo = handlerMessenger; - Bundle bundle = new Bundle(); - bundle.putString("id", pid); - bundle.putString("cmd", cmd); - bundle.putString("alpine", alpine); - msg.setData(bundle); - try { - serviceMessenger.send(msg); - } catch (RemoteException e) { - CallbackContext errorContext = getCallbackContext(pid); - if (errorContext != null) { - errorContext.error("Failed to start process: " + e.getMessage()); - cleanupCallback(pid); + Process process = builder.start(); + + // Capture stdout + BufferedReader stdOutReader = new BufferedReader( + new InputStreamReader(process.getInputStream())); + StringBuilder stdOut = new StringBuilder(); + String line; + while ((line = stdOutReader.readLine()) != null) { + stdOut.append(line).append("\n"); + } + + // Capture stderr + BufferedReader stdErrReader = new BufferedReader( + new InputStreamReader(process.getErrorStream())); + StringBuilder stdErr = new StringBuilder(); + while ((line = stdErrReader.readLine()) != null) { + stdErr.append(line).append("\n"); + } + + int exitCode = process.waitFor(); + if (exitCode == 0) { + callbackContext.success(stdOut.toString().trim()); + } else { + String errorOutput = stdErr.toString().trim(); + if (errorOutput.isEmpty()) { + errorOutput = "Command exited with code: " + exitCode; + } + callbackContext.error(errorOutput); + } + } else { + callbackContext.error("Expected one non-empty string argument."); } + } catch (Exception e) { + e.printStackTrace(); + callbackContext.error("Exception: " + e.getMessage()); } + }); } - private void exec(String execId, String cmd, String alpine) { - Message msg = Message.obtain(null, TerminalService.MSG_EXEC); - msg.replyTo = handlerMessenger; - Bundle bundle = new Bundle(); - bundle.putString("id", execId); - bundle.putString("cmd", cmd); - bundle.putString("alpine", alpine); - msg.setData(bundle); - try { - serviceMessenger.send(msg); - } catch (RemoteException e) { - CallbackContext callbackContext = getCallbackContext(execId); - if (callbackContext != null) { - callbackContext.error("Failed to execute command: " + e.getMessage()); - cleanupCallback(execId); + private void startProcess(String pid, String cmd,String alpine, CallbackContext callbackContext) { + cordova.getThreadPool().execute(() -> { + try { + String xcmd = cmd; + if(alpine.equals("true")){ + xcmd = "source $PREFIX/init-sandbox.sh "+cmd; + } + ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); + + // Set environment variables + Map env = builder.environment(); + env.put("PREFIX", context.getFilesDir().getAbsolutePath()); + env.put("NATIVE_DIR", context.getApplicationInfo().nativeLibraryDir); + TimeZone tz = TimeZone.getDefault(); + String timezoneId = tz.getID(); + env.put("ANDROID_TZ", timezoneId); + + try { + int target = context.getPackageManager().getPackageInfo(context.getPackageName(), 0).applicationInfo.targetSdkVersion; + env.put("FDROID", String.valueOf(target <= 28)); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + } + + + + Process process = builder.start(); + + processes.put(pid, process); + processInputs.put(pid, process.getOutputStream()); + processCallbacks.put(pid, callbackContext); + + PluginResult pluginResult = new PluginResult(PluginResult.Status.OK, pid); + pluginResult.setKeepCallback(true); + callbackContext.sendPluginResult(pluginResult); + + // stdout thread + new Thread(() -> streamOutput(process.getInputStream(), pid, "stdout")).start(); + // stderr thread + new Thread(() -> streamOutput(process.getErrorStream(), pid, "stderr")).start(); + + int exitCode = process.waitFor(); + sendPluginMessage(pid, "exit:" + exitCode); + cleanup(pid); + } catch (Exception e) { + callbackContext.error("Failed to start process: " + e.getMessage()); } - } + }); } private void writeToProcess(String pid, String input, CallbackContext callbackContext) { - Message msg = Message.obtain(null, TerminalService.MSG_WRITE_TO_PROCESS); - Bundle bundle = new Bundle(); - bundle.putString("id", pid); - bundle.putString("input", input); - msg.setData(bundle); try { - serviceMessenger.send(msg); - callbackContext.success("Written to process"); - } catch (RemoteException e) { + OutputStream os = processInputs.get(pid); + if (os != null) { + os.write((input + "\n").getBytes()); + os.flush(); + callbackContext.success("Written to process"); + } else { + callbackContext.error("Process not found or closed"); + } + } catch (IOException e) { callbackContext.error("Write error: " + e.getMessage()); } } private void stopProcess(String pid, CallbackContext callbackContext) { - Message msg = Message.obtain(null, TerminalService.MSG_STOP_PROCESS); - Bundle bundle = new Bundle(); - bundle.putString("id", pid); - msg.setData(bundle); - try { - serviceMessenger.send(msg); + Process process = processes.get(pid); + if (process != null) { + process.destroy(); + cleanup(pid); + // Check if we should stop the service + checkStopService(); callbackContext.success("Process terminated"); - } catch (RemoteException e) { - callbackContext.error("Stop error: " + e.getMessage()); + } else { + callbackContext.error("No such process"); } } - private void isProcessRunning(String pid) { - Message msg = Message.obtain(null, TerminalService.MSG_IS_RUNNING); - msg.replyTo = handlerMessenger; - Bundle bundle = new Bundle(); - bundle.putString("id", pid); - msg.setData(bundle); - try { - serviceMessenger.send(msg); - } catch (RemoteException e) { - CallbackContext callbackContext = getCallbackContext(pid); - if (callbackContext != null) { - callbackContext.error("Check running error: " + e.getMessage()); - cleanupCallback(pid); + private void isProcessRunning(String pid, CallbackContext callbackContext) { + Process process = processes.get(pid); + + if (process != null) { + if (process.isAlive()) { + callbackContext.success("running"); + } else { + cleanup(pid); + callbackContext.success("exited"); } + } else { + callbackContext.success("not_found"); } } - private CallbackContext getCallbackContext(String id) { - return callbackContextMap.get(id); + private void streamOutput(InputStream inputStream, String pid, String streamType) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { + String line; + while ((line = reader.readLine()) != null) { + sendPluginMessage(pid, streamType + ":" + line); + } + } catch (IOException ignored) { + } } - private void cleanupCallback(String id) { - callbackContextMap.remove(id); + private void sendPluginMessage(String pid, String message) { + CallbackContext ctx = processCallbacks.get(pid); + if (ctx != null) { + PluginResult result = new PluginResult(PluginResult.Status.OK, message); + result.setKeepCallback(true); + ctx.sendPluginResult(result); + } } @Override public void onDestroy() { super.onDestroy(); + //Intent serviceIntent = new Intent(context, KeepAliveService.class); + //context.stopService(serviceIntent); + } + + private void cleanup(String pid) { + processes.remove(pid); + processInputs.remove(pid); + processCallbacks.remove(pid); } } \ No newline at end of file diff --git a/src/plugins/terminal/src/android/KeepAliveService.java b/src/plugins/terminal/src/android/KeepAliveService.java new file mode 100644 index 000000000..ca35546b2 --- /dev/null +++ b/src/plugins/terminal/src/android/KeepAliveService.java @@ -0,0 +1,117 @@ +package com.foxdebug.acode.rk.exec.terminal; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; + +public class KeepAliveService extends Service { + + private static final String CHANNEL_ID = "keepalive_channel"; + private static final int NOTIFICATION_ID = 101; + + private PowerManager.WakeLock wakeLock; + + public static final String ACTION_ACQUIRE = "ACQUIRE_WAKELOCK"; + public static final String ACTION_EXIT = "EXIT_SERVICE"; + + @Override + public void onCreate() { + super.onCreate(); + createNotificationChannel(); + startForeground(NOTIFICATION_ID, buildNotification()); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + + if (intent != null && intent.getAction() != null) { + switch (intent.getAction()) { + + case ACTION_ACQUIRE: + acquireWakeLock(); + break; + + case ACTION_EXIT: + stopSelf(); + break; + } + } + + // Update notification (in case state changed) + startForeground(NOTIFICATION_ID, buildNotification()); + return START_STICKY; + } + + private void acquireWakeLock() { + if (wakeLock == null) { + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + wakeLock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "KeepAliveService:WakeLock"); + wakeLock.setReferenceCounted(false); + } + + if (!wakeLock.isHeld()) { + wakeLock.acquire(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private Notification buildNotification() { + + // Intent: Acquire WakeLock + Intent acquireIntent = new Intent(this, KeepAliveService.class); + acquireIntent.setAction(ACTION_ACQUIRE); + + PendingIntent acquirePending = PendingIntent.getService( + this, 0, acquireIntent, PendingIntent.FLAG_IMMUTABLE); + + // Intent: Exit Service + Intent exitIntent = new Intent(this, KeepAliveService.class); + exitIntent.setAction(ACTION_EXIT); + + PendingIntent exitPending = PendingIntent.getService( + this, 1, exitIntent, PendingIntent.FLAG_IMMUTABLE); + + Notification.Builder builder = new Notification.Builder(this, CHANNEL_ID) + .setContentTitle("KeepAlive Service") + .setContentText("Running in foreground") + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .addAction(0, "Acquire WakeLock", acquirePending) + .addAction(0, "Exit", exitPending) + .setOngoing(true); + + return builder.build(); + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "KeepAlive", + NotificationManager.IMPORTANCE_LOW); + + NotificationManager manager = getSystemService(NotificationManager.class); + manager.createNotificationChannel(channel); + } + } +} diff --git a/src/plugins/terminal/src/android/TerminalService.java b/src/plugins/terminal/src/android/TerminalService.java deleted file mode 100644 index f8e26c67f..000000000 --- a/src/plugins/terminal/src/android/TerminalService.java +++ /dev/null @@ -1,413 +0,0 @@ -package com.foxdebug.acode.rk.exec.terminal; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.Message; -import android.os.Messenger; -import android.os.PowerManager; -import android.os.RemoteException; -import androidx.core.app.NotificationCompat; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.lang.reflect.Field; - -import java.util.TimeZone; -import java.util.Map; -import java.util.HashMap; - - - -public class TerminalService extends Service { - - public static final int MSG_START_PROCESS = 1; - public static final int MSG_WRITE_TO_PROCESS = 2; - public static final int MSG_STOP_PROCESS = 3; - public static final int MSG_IS_RUNNING = 4; - public static final int MSG_EXEC = 5; - - public static final String CHANNEL_ID = "terminal_exec_channel"; - - public static final String ACTION_EXIT_SERVICE = "com.foxdebug.acode.ACTION_EXIT_SERVICE"; - public static final String ACTION_TOGGLE_WAKE_LOCK = "com.foxdebug.acode.ACTION_TOGGLE_WAKE_LOCK"; - - private final Map processes = new ConcurrentHashMap<>(); - private final Map processInputs = new ConcurrentHashMap<>(); - private final Map clientMessengers = new ConcurrentHashMap<>(); - private final java.util.concurrent.ExecutorService threadPool = Executors.newCachedThreadPool(); - - private final Messenger serviceMessenger = new Messenger(new ServiceHandler()); - - private PowerManager.WakeLock wakeLock; - private boolean isWakeLockHeld = false; - - @Override - public IBinder onBind(Intent intent) { - return serviceMessenger.getBinder(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null) { - String action = intent.getAction(); - if (ACTION_EXIT_SERVICE.equals(action)) { - stopForeground(true); - stopSelf(); - return START_NOT_STICKY; - } else if (ACTION_TOGGLE_WAKE_LOCK.equals(action)) { - toggleWakeLock(); - } - } - return START_STICKY; - } - - private class ServiceHandler extends Handler { - @Override - public void handleMessage(Message msg) { - Bundle bundle = msg.getData(); - String id = bundle.getString("id"); - Messenger clientMessenger = msg.replyTo; - - switch (msg.what) { - case MSG_START_PROCESS: - String cmd = bundle.getString("cmd"); - String alpine = bundle.getString("alpine"); - clientMessengers.put(id, clientMessenger); - startProcess(id, cmd, alpine); - break; - case MSG_WRITE_TO_PROCESS: - String input = bundle.getString("input"); - writeToProcess(id, input); - break; - case MSG_STOP_PROCESS: - stopProcess(id); - break; - case MSG_IS_RUNNING: - isProcessRunning(id, clientMessenger); - break; - case MSG_EXEC: - String execCmd = bundle.getString("cmd"); - String execAlpine = bundle.getString("alpine"); - clientMessengers.put(id, clientMessenger); - exec(id, execCmd, execAlpine); - break; - } - } - } - - private void toggleWakeLock() { - if (isWakeLockHeld) { - releaseWakeLock(); - } else { - acquireWakeLock(); - } - updateNotification(); - } - - private void acquireWakeLock() { - if (wakeLock == null) { - PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); - wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AcodeTerminal:WakeLock"); - } - - if (!isWakeLockHeld) { - wakeLock.acquire(); - isWakeLockHeld = true; - } - } - - private void releaseWakeLock() { - if (wakeLock != null && isWakeLockHeld) { - wakeLock.release(); - isWakeLockHeld = false; - } - } - - private void startProcess(String pid, String cmd, String alpine) { - threadPool.execute(() -> { - try { - String xcmd = alpine.equals("true") ? "source $PREFIX/init-sandbox.sh " + cmd : cmd; - ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); - - Map env = builder.environment(); - env.put("PREFIX", getFilesDir().getAbsolutePath()); - env.put("NATIVE_DIR", getApplicationInfo().nativeLibraryDir); - TimeZone tz = TimeZone.getDefault(); - String timezoneId = tz.getID(); - env.put("ANDROID_TZ", timezoneId); - - try { - int target = getPackageManager().getPackageInfo(getPackageName(), 0).applicationInfo.targetSdkVersion; - env.put("FDROID", String.valueOf(target <= 28)); - } catch (Exception e) { - e.printStackTrace(); - } - - Process process = builder.start(); - processes.put(pid, process); - processInputs.put(pid, process.getOutputStream()); - threadPool.execute(() -> streamOutput(process.getInputStream(), pid, "stdout")); - threadPool.execute(() -> streamOutput(process.getErrorStream(), pid, "stderr")); - threadPool.execute(() -> { - try { - int exitCode = process.waitFor(); - sendMessageToClient(pid, "exit", String.valueOf(exitCode)); - cleanup(pid); - } catch (InterruptedException e) { - e.printStackTrace(); - } - }); - } catch (IOException e) { - e.printStackTrace(); - sendMessageToClient(pid, "stderr", "Failed to start process: " + e.getMessage()); - sendMessageToClient(pid, "exit", "1"); - cleanup(pid); - } - }); - } - - private void exec(String execId, String cmd, String alpine) { - threadPool.execute(() -> { - try { - String xcmd = alpine.equals("true") ? "source $PREFIX/init-sandbox.sh " + cmd : cmd; - ProcessBuilder builder = new ProcessBuilder("sh", "-c", xcmd); - Map env = builder.environment(); - env.put("PREFIX", getFilesDir().getAbsolutePath()); - env.put("NATIVE_DIR", getApplicationInfo().nativeLibraryDir); - TimeZone tz = TimeZone.getDefault(); - String timezoneId = tz.getID(); - env.put("ANDROID_TZ", timezoneId); - - try { - int target = getPackageManager().getPackageInfo(getPackageName(), 0).applicationInfo.targetSdkVersion; - env.put("FDROID", String.valueOf(target <= 28)); - } catch (Exception e) { - e.printStackTrace(); - } - - Process process = builder.start(); - BufferedReader stdOutReader = new BufferedReader( - new InputStreamReader(process.getInputStream())); - StringBuilder stdOut = new StringBuilder(); - String line; - while ((line = stdOutReader.readLine()) != null) { - stdOut.append(line).append("\n"); - } - - BufferedReader stdErrReader = new BufferedReader( - new InputStreamReader(process.getErrorStream())); - StringBuilder stdErr = new StringBuilder(); - while ((line = stdErrReader.readLine()) != null) { - stdErr.append(line).append("\n"); - } - - int exitCode = process.waitFor(); - - if (exitCode == 0) { - sendExecResultToClient(execId, true, stdOut.toString().trim()); - } else { - String errorOutput = stdErr.toString().trim(); - if (errorOutput.isEmpty()) { - errorOutput = "Command exited with code: " + exitCode; - } - sendExecResultToClient(execId, false, errorOutput); - } - - cleanup(execId); - } catch (Exception e) { - sendExecResultToClient(execId, false, "Exception: " + e.getMessage()); - cleanup(execId); - } - }); - } - - private void streamOutput(InputStream inputStream, String pid, String streamType) { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) { - String line; - while ((line = reader.readLine()) != null) { - sendMessageToClient(pid, streamType, line); - } - } catch (IOException ignored) { - } - } - - private void sendMessageToClient(String id, String action, String data) { - Messenger clientMessenger = clientMessengers.get(id); - if (clientMessenger != null) { - try { - Message msg = Message.obtain(); - Bundle bundle = new Bundle(); - bundle.putString("id", id); - bundle.putString("action", action); - bundle.putString("data", data); - msg.setData(bundle); - clientMessenger.send(msg); - } catch (RemoteException e) { - cleanup(id); - } - } - } - - private void sendExecResultToClient(String id, boolean isSuccess, String data) { - Messenger clientMessenger = clientMessengers.get(id); - if (clientMessenger != null) { - try { - Message msg = Message.obtain(); - Bundle bundle = new Bundle(); - bundle.putString("id", id); - bundle.putString("action", "exec_result"); - bundle.putString("data", data); - bundle.putBoolean("isSuccess", isSuccess); - msg.setData(bundle); - clientMessenger.send(msg); - } catch (RemoteException e) { - cleanup(id); - } - } - } - - private void writeToProcess(String pid, String input) { - try { - OutputStream os = processInputs.get(pid); - if (os != null) { - os.write((input + "\n").getBytes()); - os.flush(); - } - } catch (IOException e) { - e.printStackTrace(); - } - } - - private long getPid(Process process) { - try { - Field f = process.getClass().getDeclaredField("pid"); - f.setAccessible(true); - return f.getLong(process); - } catch (Exception e) { - return -1; - } -} - - - private void stopProcess(String pid) { - Process process = processes.get(pid); - if (process != null) { - try { - Runtime.getRuntime().exec("kill -9 -" + getPid(process)); - } catch (Exception ignored) {} - process.destroy(); - cleanup(pid); - } - } - - private void isProcessRunning(String pid, Messenger clientMessenger) { - Process process = processes.get(pid); - String status = process != null && isProcessAlive(process) ? "running" : "not_found"; - sendMessageToClient(pid, "isRunning", status); - } - - private boolean isProcessAlive(Process process) { - try { - process.exitValue(); - return false; - } catch(IllegalThreadStateException e) { - return true; - } - } - - private void cleanup(String id) { - processes.remove(id); - processInputs.remove(id); - clientMessengers.remove(id); - } - - @Override - public void onCreate() { - super.onCreate(); - createNotificationChannel(); - updateNotification(); - } - - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel serviceChannel = new NotificationChannel( - CHANNEL_ID, - "Terminal Executor Channel", - NotificationManager.IMPORTANCE_LOW - ); - NotificationManager manager = getSystemService(NotificationManager.class); - if (manager != null) { - manager.createNotificationChannel(serviceChannel); - } - } - } - - private void updateNotification() { - Intent exitIntent = new Intent(this, TerminalService.class); - exitIntent.setAction(ACTION_EXIT_SERVICE); - PendingIntent exitPendingIntent = PendingIntent.getService(this, 0, exitIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - Intent wakeLockIntent = new Intent(this, TerminalService.class); - wakeLockIntent.setAction(ACTION_TOGGLE_WAKE_LOCK); - PendingIntent wakeLockPendingIntent = PendingIntent.getService(this, 1, wakeLockIntent, - PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); - - String contentText = "Executor service" + (isWakeLockHeld ? " (wakelock held)" : ""); - String wakeLockButtonText = isWakeLockHeld ? "Release Wake Lock" : "Acquire Wake Lock"; - - int notificationIcon = resolveDrawableId("ic_notification", "ic_launcher_foreground", "ic_launcher"); - - Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle("Acode Service") - .setContentText(contentText) - .setSmallIcon(notificationIcon) - .setOngoing(true) - .addAction(notificationIcon, wakeLockButtonText, wakeLockPendingIntent) - .addAction(notificationIcon, "Exit", exitPendingIntent) - .build(); - - startForeground(1, notification); - } - - @Override - public void onDestroy() { - stopForeground(true); - super.onDestroy(); - releaseWakeLock(); - - for (Process process : processes.values()) { - try { - Runtime.getRuntime().exec("kill -9 -" + getPid(process)); - } catch (Exception ignored) {} - process.destroyForcibly(); - } - - processes.clear(); - processInputs.clear(); - clientMessengers.clear(); - threadPool.shutdown(); - } - - private int resolveDrawableId(String... names) { - for (String name : names) { - int id = getResources().getIdentifier(name, "drawable", getPackageName()); - if (id != 0) return id; - } - return android.R.drawable.sym_def_app_icon; - } -} diff --git a/src/plugins/terminal/www/Executor.js b/src/plugins/terminal/www/Executor.js index 78103fafc..845e018f6 100644 --- a/src/plugins/terminal/www/Executor.js +++ b/src/plugins/terminal/www/Executor.js @@ -30,33 +30,22 @@ const Executor = { */ - start(command, onData) { - this.start(command, onData, false); + start(command,onData){ + this.start(command,onData,true) }, start(command, onData, alpine) { - console.log("start: " + command); - return new Promise((resolve, reject) => { - let first = true; - exec(async (message) => { - console.log(message); - if (first) { - first = false; - await new Promise(resolve => setTimeout(resolve, 100)); + exec( + (message) => { + // Stream stdout, stderr, or exit notifications + if (message.startsWith("stdout:")) return onData("stdout", message.slice(7)); + if (message.startsWith("stderr:")) return onData("stderr", message.slice(7)); + if (message.startsWith("exit:")) return onData("exit", message.slice(5)); + // First message is always the process UUID resolve(message); - } else { - const match = message.match(/^([^:]+):(.*)$/); - if (match) { - const prefix = match[1]; // e.g. "stdout" - const message = match[2].trim(); // output - onData(prefix, message); - } else { - onData("unknown", message); - } - } - }, + }, reject, "Executor", "start", @@ -73,10 +62,9 @@ const Executor = { * @returns {Promise} Resolves once the input is written. * * @example - * Executor.write(uuid, 'ls /sdcard'); + * Executor.write(uuid, 'ls /data'); */ write(uuid, input) { - console.log("write: " + input + " to " + uuid); return new Promise((resolve, reject) => { exec(resolve, reject, "Executor", "write", [uuid, input]); }); @@ -114,12 +102,6 @@ const Executor = { }); }, - stopService() { - return new Promise((resolve, reject) => { - exec(resolve, reject, "Executor", "stopService", []); - }); - }, - /** * Executes a shell command once and waits for it to finish. * Unlike {@link Executor.start}, this does not stream output. @@ -133,8 +115,8 @@ const Executor = { * .then(console.log) * .catch(console.error); */ - execute(command) { - this.execute(command, false); + execute(command){ + this.execute(command,false) } , execute(command, alpine) { @@ -143,7 +125,7 @@ const Executor = { }); }, - loadLibrary(path) { + loadLibrary(path){ return new Promise((resolve, reject) => { exec(resolve, reject, "Executor", "loadLibrary", [path]); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..b84c64980 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowJs": true, + "checkJs": false, + "outDir": "./dist", + "sourceMap": true, + "resolveJsonModule": true, + "skipLibCheck": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "www", "dist"] +} diff --git a/webpack.config.js b/webpack.config.js index 29c42385d..f449a2e39 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -36,6 +36,13 @@ module.exports = (env, options) => { }, ]; + rules.push({ + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/, + } + ) + // if (mode === 'production') { rules.push({ test: /\.m?js$/, @@ -68,13 +75,14 @@ module.exports = (env, options) => { module: { rules, }, - resolve: { - fallback: { - path: require.resolve('path-browserify'), - crypto: false, + resolve: { + extensions: ['.ts', '.js'], + fallback: { + path: require.resolve('path-browserify'), + crypto: false, + }, + modules: ["node_modules", "src"], }, - modules: ["node_modules", "src"], - }, plugins: [ new MiniCssExtractPlugin({ filename: '[name].css',