From e612a169de54ddf1ca9b7dfdf25a5923504dcdbc Mon Sep 17 00:00:00 2001 From: lyswhut Date: Fri, 3 Nov 2023 21:04:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=BA=90=EF=BC=8C=E4=BD=86=E9=9C=80=E8=A6=81=E6=B3=A8=E6=84=8F?= =?UTF-8?q?=E7=9A=84=E6=98=AF=EF=BC=8C=E7=A7=BB=E5=8A=A8=E7=AB=AF=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=BA=90=E7=9A=84=E7=8E=AF=E5=A2=83=E4=B8=8E?= =?UTF-8?q?PC=E7=AB=AF=E4=B8=8D=E5=90=8C=EF=BC=8C=E6=9F=90=E4=BA=9BAPI?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle | 3 + .../main/assets/script/user-api-preload.js | 507 ++++++++++++++++++ .../toside/music/mobile/MainApplication.java | 2 + .../toside/music/mobile/userApi/Console.java | 43 ++ .../music/mobile/userApi/HandlerWhat.java | 9 + .../mobile/userApi/JavaScriptThread.java | 63 +++ .../music/mobile/userApi/JsHandler.java | 69 +++ .../toside/music/mobile/userApi/QuickJS.java | 188 +++++++ .../music/mobile/userApi/UserApiModule.java | 69 +++ .../music/mobile/userApi/UserApiPackage.java | 21 + .../music/mobile/userApi/UtilsEvent.java | 24 + publish/changeLog.md | 4 + src/components/common/CheckBox/Checkbox.tsx | 9 +- src/components/common/CheckBox/index.tsx | 15 +- src/config/constant.ts | 3 + src/config/globalData.ts | 2 + src/core/apiSource.ts | 24 +- src/core/init/index.ts | 8 +- src/core/init/userApi.ts | 172 ++++++ src/core/userApi.ts | 69 +++ src/lang/en_us.json | 14 + src/lang/zh_cn.json | 14 + .../Views/Setting/settings/Basic/Source.tsx | 48 +- .../settings/Basic/UserApiEditModal/List.tsx | 119 ++++ .../UserApiEditModal/ScriptImportExport.tsx | 98 ++++ .../settings/Basic/UserApiEditModal/action.ts | 17 + .../settings/Basic/UserApiEditModal/index.tsx | 182 +++++++ .../Home/Views/Setting/settings/Other/Log.tsx | 7 + src/store/userApi/action.ts | 31 ++ src/store/userApi/event.ts | 26 + src/store/userApi/hook.ts | 29 + src/store/userApi/index.ts | 4 + src/store/userApi/state.ts | 22 + src/types/app.d.ts | 2 + src/types/user_api.d.ts | 60 +++ src/utils/data.ts | 52 ++ src/utils/musicSdk/api-source.js | 1 + src/utils/nativeModules/userApi.ts | 89 +++ 38 files changed, 2090 insertions(+), 29 deletions(-) create mode 100644 android/app/src/main/assets/script/user-api-preload.js create mode 100644 android/app/src/main/java/cn/toside/music/mobile/userApi/Console.java create mode 100644 android/app/src/main/java/cn/toside/music/mobile/userApi/HandlerWhat.java create mode 100644 android/app/src/main/java/cn/toside/music/mobile/userApi/JavaScriptThread.java create mode 100644 android/app/src/main/java/cn/toside/music/mobile/userApi/JsHandler.java create mode 100644 android/app/src/main/java/cn/toside/music/mobile/userApi/QuickJS.java create mode 100644 android/app/src/main/java/cn/toside/music/mobile/userApi/UserApiModule.java create mode 100644 android/app/src/main/java/cn/toside/music/mobile/userApi/UserApiPackage.java create mode 100644 android/app/src/main/java/cn/toside/music/mobile/userApi/UtilsEvent.java create mode 100644 src/core/init/userApi.ts create mode 100644 src/core/userApi.ts create mode 100644 src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/List.tsx create mode 100644 src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/ScriptImportExport.tsx create mode 100644 src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/action.ts create mode 100644 src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/index.tsx create mode 100644 src/store/userApi/action.ts create mode 100644 src/store/userApi/event.ts create mode 100644 src/store/userApi/hook.ts create mode 100644 src/store/userApi/index.ts create mode 100644 src/store/userApi/state.ts create mode 100644 src/types/user_api.d.ts create mode 100644 src/utils/nativeModules/userApi.ts diff --git a/android/app/build.gradle b/android/app/build.gradle index 4f08733..88d98b7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -188,6 +188,9 @@ android { dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") + // implementation "androidx.javascriptengine:javascriptengine:1.0.0-alpha07" + // implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + implementation group: 'wang.harlon.quickjs', name: 'wrapper-android', version: '1.0.0' debugImplementation("com.facebook.flipper:flipper:${FLIPPER_VERSION}") debugImplementation("com.facebook.flipper:flipper-network-plugin:${FLIPPER_VERSION}") { diff --git a/android/app/src/main/assets/script/user-api-preload.js b/android/app/src/main/assets/script/user-api-preload.js new file mode 100644 index 0000000..45a3c3e --- /dev/null +++ b/android/app/src/main/assets/script/user-api-preload.js @@ -0,0 +1,507 @@ + +globalThis.lx_setup = (key, id, name, description, rawScript) => { + delete globalThis.setup + const _nativeCall = globalThis.__lx_native_call__ + delete globalThis.__lx_native_call__ + const set_timeout = globalThis.__lx_native_call__set_timeout + delete globalThis.__lx_native_call__set_timeout + const utils_str2b64 = globalThis.__lx_native_call__utils_str2b64 + delete globalThis.__lx_native_call__utils_str2b64 + const utils_b642buf = globalThis.__lx_native_call__utils_b642buf + delete globalThis.__lx_native_call__utils_b642buf + const utils_str2md5 = globalThis.__lx_native_call__utils_str2md5 + delete globalThis.__lx_native_call__utils_str2md5 + const utils_aes_encrypt = globalThis.__lx_native_call__utils_aes_encrypt + delete globalThis.__lx_native_call__utils_aes_encrypt + const utils_rsa_encrypt = globalThis.__lx_native_call__utils_rsa_encrypt + delete globalThis.__lx_native_call__utils_rsa_encrypt + const KEY_PREFIX = { + publicKeyStart: '-----BEGIN PUBLIC KEY-----', + publicKeyEnd: '-----END PUBLIC KEY-----', + privateKeyStart: '-----BEGIN PRIVATE KEY-----', + privateKeyEnd: '-----END PRIVATE KEY-----', + } + const RSA_PADDING = { + OAEPWithSHA1AndMGF1Padding: 'RSA/ECB/OAEPWithSHA1AndMGF1Padding', + NoPadding: 'RSA/ECB/NoPadding', + } + const AES_MODE = { + CBC_128_PKCS7Padding: 'AES/CBC/PKCS7Padding', + ECB_128_NoPadding: 'AES', + } + const nativeCall = (action, data) => { + data = JSON.stringify(data) + // console.log('nativeCall', action, data) + _nativeCall(key, action, data) + } + + const callbacks = new Map() + let timeoutId = 0 + const setTimeout = (callback, timeout = 0, ...params) => { + if (typeof timeout !== 'number' || timeout < 0) throw new Error('timeout required number') + if (timeoutId > 90000000000) throw new Error('max timeout') + const id = timeoutId++ + callbacks.set(id, { + callback, + params, + }) + set_timeout(id, parseInt(timeout)) + return id + } + const clearTimeout = (id) => { + const tagret = callbacks.get(id) + if (!tagret) return + callbacks.delete(id) + } + const handleSetTimeout = (id) => { + const tagret = callbacks.get(id) + if (!tagret) return + callbacks.delete(id) + tagret.callback(...tagret.params) + } + + // 将字节数组解码为字符串(UTF-8) + function bytesToString(bytes) { + let result = '' + let i = 0 + while (i < bytes.length) { + const byte = bytes[i] + if (byte < 128) { + result += String.fromCharCode(byte) + i++ + } else if (byte >= 192 && byte < 224) { + result += String.fromCharCode(((byte & 31) << 6) | (bytes[i + 1] & 63)) + i += 2 + } else { + result += String.fromCharCode(((byte & 15) << 12) | ((bytes[i + 1] & 63) << 6) | (bytes[i + 2] & 63)) + i += 3 + } + } + return result + } + // 将字符串编码为字节数组(UTF-8) + function stringToBytes(inputString) { + const bytes = [] + for (let i = 0; i < inputString.length; i++) { + const charCode = inputString.charCodeAt(i) + if (charCode < 128) { + bytes.push(charCode) + } else if (charCode < 2048) { + bytes.push((charCode >> 6) | 192) + bytes.push((charCode & 63) | 128) + } else { + bytes.push((charCode >> 12) | 224) + bytes.push(((charCode >> 6) & 63) | 128) + bytes.push((charCode & 63) | 128) + } + } + return bytes + } + + const NATIVE_EVENTS_NAMES = { + init: 'init', + showUpdateAlert: 'showUpdateAlert', + request: 'request', + cancelRequest: 'cancelRequest', + response: 'response', + 'utils.crypto.aesEncrypt': 'utils.crypto.aesEncrypt', + 'utils.crypto.rsaEncrypt': 'utils.crypto.rsaEncrypt', + 'utils.crypto.randomBytes': 'utils.crypto.randomBytes', + 'utils.crypto.md5': 'utils.crypto.md5', + 'utils.buffer.from': 'utils.buffer.from', + 'utils.buffer.bufToString': 'utils.buffer.bufToString', + 'utils.zlib.inflate': 'utils.zlib.inflate', + 'utils.zlib.deflate': 'utils.zlib.deflate', + } + const EVENT_NAMES = Object.freeze({ + request: 'request', + inited: 'inited', + updateAlert: 'updateAlert', + }) + const eventNames = Object.values(EVENT_NAMES) + const events = { + request: null, + } + const allSources = ['kw', 'kg', 'tx', 'wy', 'mg'] + const supportQualitys = { + kw: ['128k', '320k', 'flac', 'flac24bit'], + kg: ['128k', '320k', 'flac', 'flac24bit'], + tx: ['128k', '320k', 'flac', 'flac24bit'], + wy: ['128k', '320k', 'flac', 'flac24bit'], + mg: ['128k', '320k', 'flac', 'flac24bit'], + } + const supportActions = { + kw: ['musicUrl'], + kg: ['musicUrl'], + tx: ['musicUrl'], + wy: ['musicUrl'], + mg: ['musicUrl'], + xm: ['musicUrl'], + } + const requestQueue = new Map() + let isInitedApi = false + let isShowedUpdateAlert = false + + const sendNativeRequest = (url, options, callback) => { + const requestKey = Math.random().toString() + const requestInfo = { + aborted: false, + abort: () => { + nativeCall(NATIVE_EVENTS_NAMES.cancelRequest, requestKey) + }, + } + requestQueue.set(requestKey, { + callback, + // timeout: setTimeout(() => { + // const req = requestQueue.get(requestKey) + // if (req) req.timeout = null + // nativeCall(NATIVE_EVENTS_NAMES.cancelRequest, requestKey) + // }, 30000), + requestInfo, + }) + + nativeCall(NATIVE_EVENTS_NAMES.request, { requestKey, url, options }) + return requestInfo + } + const handleNativeResponse = ({ requestKey, error, response }) => { + const targetRequest = requestQueue.get(requestKey) + if (!targetRequest) return + requestQueue.delete(requestKey) + targetRequest.requestInfo.aborted = true + // if (targetRequest.timeout) clearTimeout(targetRequest.timeout) + if (error == null) targetRequest.callback(null, response) + else targetRequest.callback(new Error(error), null) + } + + const handleRequest = ({ requestKey, data }) => { + // console.log(data) + if (!events.request) return nativeCall(NATIVE_EVENTS_NAMES.response, { requestKey, status: false, errorMessage: 'Request event is not defined' }) + try { + events.request.call(globalThis.lx, { source: data.source, action: data.action, info: data.info }).then(response => { + let result + switch (data.action) { + case 'musicUrl': + result = { + source: data.source, + action: data.action, + data: { + type: data.info.type, + url: response, + }, + } + break + } + nativeCall(NATIVE_EVENTS_NAMES.response, { requestKey, status: true, result }) + }).catch(err => { + // console.log('handleRequest err', err) + nativeCall(NATIVE_EVENTS_NAMES.response, { requestKey, status: false, errorMessage: err.message }) + }) + } catch (err) { + // console.log('handleRequest call err', err) + nativeCall(NATIVE_EVENTS_NAMES.response, { requestKey, status: false, errorMessage: err.message }) + } + } + + const jsCall = (action, data) => { + // console.log('jsCall', action, data) + switch (action) { + case '__set_timeout__': + handleSetTimeout(data) + break + case 'request': + handleRequest(data) + break + case 'response': + handleNativeResponse(data) + break + case NATIVE_EVENTS_NAMES['utils.buffer.from']: + break + case NATIVE_EVENTS_NAMES['utils.buffer.bufToString']: + + break + + default: + break + } + return 'Unknown action: ' + action + } + + Object.defineProperty(globalThis, '__lx_native__', { + enumerable: false, + configurable: false, + writable: false, + value: (_key, action, data) => { + if (key != _key) return 'Invalid key' + return jsCall(action, JSON.parse(data)) + }, + }) + + + /** + * + * @param {*} info { + * openDevTools: false, + * status: true, + * message: 'xxx', + * sources: { + * kw: ['128k', '320k', 'flac', 'flac24bit'], + * kg: ['128k', '320k', 'flac', 'flac24bit'], + * tx: ['128k', '320k', 'flac', 'flac24bit'], + * wy: ['128k', '320k', 'flac', 'flac24bit'], + * mg: ['128k', '320k', 'flac', 'flac24bit'], + * } + * } + */ + const handleInit = (info) => { + if (!info) { + nativeCall(NATIVE_EVENTS_NAMES.init, { id, info: null, status: false, errorMessage: 'Init failed' }) + // sendMessage(NATIVE_EVENTS_NAMES.init, false, null, typeof info.message === 'string' ? info.message.substring(0, 100) : '') + return + } + if (!info.status) { + nativeCall(NATIVE_EVENTS_NAMES.init, { id, info: null, status: false, errorMessage: 'Init failed' }) + // sendMessage(NATIVE_EVENTS_NAMES.init, false, null, typeof info.message === 'string' ? info.message.substring(0, 100) : '') + return + } + const sourceInfo = { + sources: {}, + } + try { + for (const source of allSources) { + const userSource = info.sources[source] + if (!userSource || userSource.type !== 'music') continue + const qualitys = supportQualitys[source] + const actions = supportActions[source] + sourceInfo.sources[source] = { + type: 'music', + actions: actions.filter(a => userSource.actions.includes(a)), + qualitys: qualitys.filter(q => userSource.qualitys.includes(q)), + } + } + } catch (error) { + // console.log(error) + nativeCall(NATIVE_EVENTS_NAMES.init, { id, info: null, status: false, errorMessage: error.message }) + return + } + nativeCall(NATIVE_EVENTS_NAMES.init, { id, info: sourceInfo, status: true }) + } + const handleShowUpdateAlert = (data, resolve, reject) => { + if (!data || typeof data != 'object') return reject(new Error('parameter format error.')) + if (!data.log || typeof data.log != 'string') return reject(new Error('log is required.')) + if (data.updateUrl && !/^https?:\/\/[^\s$.?#].[^\s]*$/.test(data.updateUrl) && data.updateUrl.length > 1024) delete data.updateUrl + if (data.log.length > 1024) data.log = data.log.substring(0, 1024) + '...' + nativeCall(NATIVE_EVENTS_NAMES.showUpdateAlert, { log: data.log, updateUrl: data.updateUrl, name }) + resolve() + } + + const dataToB64 = (data) => { + if (typeof data === 'string') return utils_str2b64(data) + else if (Array.isArray(data) || ArrayBuffer.isView(data)) return utils.buffer.bufToString(data, 'base64') + throw new Error('data type error: ' + typeof data + ' raw data: ' + data) + } + const utils = Object.freeze({ + crypto: { + aesEncrypt(buffer, mode, key, iv) { + // console.log('aesEncrypt', buffer, mode, key, iv) + switch (mode) { + case 'aes-128-cbc': + return utils.buffer.from(utils_aes_encrypt(dataToB64(buffer), dataToB64(key), dataToB64(iv), AES_MODE.CBC_128_PKCS7Padding), 'base64') + case 'aes-128-ecb': + return utils.buffer.from(utils_aes_encrypt(dataToB64(buffer), dataToB64(key), '', AES_MODE.ECB_128_NoPadding), 'base64') + default: + throw new Error('Binary encoding is not supported for input strings') + } + }, + rsaEncrypt(buffer, key) { + // console.log('rsaEncrypt', buffer, key) + if (typeof key !== 'string') throw new Error('Invalid RSA key') + key = key.replace(KEY_PREFIX.publicKeyStart, '') + .replace(KEY_PREFIX.publicKeyEnd, '') + return utils.buffer.from(utils_rsa_encrypt(dataToB64(buffer), key, RSA_PADDING.NoPadding), 'base64') + }, + randomBytes(size) { + const byteArray = new Uint8Array(size) + for (let i = 0; i < size; i++) { + byteArray[i] = Math.floor(Math.random() * 256) // 随机生成一个字节的值(0-255) + } + return byteArray + }, + md5(str) { + if (typeof str !== 'string') throw new Error('param required a string') + const md5 = utils_str2md5(str) + // console.log('md5', str, md5) + return md5 + }, + }, + buffer: { + from(input, encoding) { + // console.log('buffer.from', input, encoding) + if (typeof input === 'string') { + switch (encoding) { + case 'binary': + throw new Error('Binary encoding is not supported for input strings') + case 'base64': + return new Uint8Array(JSON.parse(utils_b642buf(input))) + case 'hex': + return new Uint8Array(input.match(/.{1,2}/g).map(byte => parseInt(byte, 16))) + default: + return new Uint8Array(stringToBytes(input)) + } + } else if (Array.isArray(input)) { + return new Uint8Array(input) + } else { + throw new Error('Unsupported input type: ' + input + ' encoding: ' + encoding) + } + }, + bufToString(buf, format) { + // console.log('buffer.bufToString', buf, format) + if (Array.isArray(buf) || ArrayBuffer.isView(buf)) { + switch (format) { + case 'binary': + // return new TextDecoder('latin1').decode(new Uint8Array(buf)) + return buf + case 'hex': + return new Uint8Array(buf).reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '') + case 'base64': + return utils_str2b64(bytesToString(Array.from(buf))) + case 'utf8': + case 'utf-8': + default: + return bytesToString(Array.from(buf)) + } + } else { + throw new Error('Input is not a valid buffer: ' + buf + ' format: ' + format) + } + }, + }, + // zlib: { + // inflate(buf) { + // return new Promise((resolve, reject) => { + // zlib.inflate(buf, (err, data) => { + // if (err) reject(new Error(err.message)) + // else resolve(data) + // }) + // }) + // }, + // deflate(data) { + // return new Promise((resolve, reject) => { + // zlib.deflate(data, (err, buf) => { + // if (err) reject(new Error(err.message)) + // else resolve(buf) + // }) + // }) + // }, + // }, + }) + Object.defineProperty(globalThis, 'lx', { + configurable: false, + writable: false, + value: Object.freeze({ + EVENT_NAMES, + request(url, { method = 'get', timeout, headers, body, form, formData, binary }, callback) { + let options = { headers, binary: binary === true } + // let data + // if (body) { + // data = body + // } else if (form) { + // data = form + // // data.content_type = 'application/x-www-form-urlencoded' + // options.json = false + // } else if (formData) { + // data = formData + // // data.content_type = 'multipart/form-data' + // options.json = false + // } + if (timeout && typeof timeout == 'number') options.timeout = Math.min(options.timeout, 60_000) + + let request = sendNativeRequest(url, { method, body, form, formData, ...options }, (err, resp) => { + callback(err, { + statusCode: resp.statusCode, + statusMessage: resp.statusMessage, + headers: resp.headers, + // bytes: resp.bytes, + // raw: resp.raw, + body: resp.body, + }, resp.body) + }) + + return () => { + if (!request.aborted) request.abort() + request = null + } + }, + send(eventName, data) { + return new Promise((resolve, reject) => { + if (!eventNames.includes(eventName)) return reject(new Error('The event is not supported: ' + eventName)) + switch (eventName) { + case EVENT_NAMES.inited: + if (isInitedApi) return reject(new Error('Script is inited')) + isInitedApi = true + handleInit(data) + resolve() + break + case EVENT_NAMES.updateAlert: + if (isShowedUpdateAlert) return reject(new Error('The update alert can only be called once.')) + isShowedUpdateAlert = true + handleShowUpdateAlert(data, resolve, reject) + break + default: + reject(new Error('Unknown event name: ' + eventName)) + } + }) + }, + on(eventName, handler) { + if (!eventNames.includes(eventName)) return Promise.reject(new Error('The event is not supported: ' + eventName)) + switch (eventName) { + case EVENT_NAMES.request: + events.request = handler + break + default: return Promise.reject(new Error('The event is not supported: ' + eventName)) + } + return Promise.resolve() + }, + utils, + currentScriptInfo: Object.freeze({ + name, + description, + }), + version: '1.0.0', + env: 'mobile', + }), + }) + + + Object.defineProperty(globalThis, 'setTimeout', { + configurable: false, + writable: false, + value: setTimeout, + }) + Object.defineProperty(globalThis, 'clearTimeout', { + configurable: false, + writable: false, + value: clearTimeout, + }) + Object.defineProperty(globalThis, 'window', { + configurable: false, + writable: false, + value: globalThis, + }) + Object.defineProperty(globalThis, 'document', { + configurable: false, + writable: false, + value: Object.freeze({ + getElementsByTagName(name) { + if (name == 'script') { + return [ + Object.freeze({ + innerText: rawScript, + }), + ] + } + return null + }, + }), + }) + + console.log('Preload finished.') +} diff --git a/android/app/src/main/java/cn/toside/music/mobile/MainApplication.java b/android/app/src/main/java/cn/toside/music/mobile/MainApplication.java index 824f7e0..36c1562 100644 --- a/android/app/src/main/java/cn/toside/music/mobile/MainApplication.java +++ b/android/app/src/main/java/cn/toside/music/mobile/MainApplication.java @@ -15,6 +15,7 @@ import cn.toside.music.mobile.cache.CachePackage; import cn.toside.music.mobile.crypto.CryptoPackage; import cn.toside.music.mobile.gzip.GzipPackage; import cn.toside.music.mobile.lyric.LyricPackage; +import cn.toside.music.mobile.userApi.UserApiPackage; import cn.toside.music.mobile.utils.UtilsPackage; public class MainApplication extends NavigationApplication { @@ -37,6 +38,7 @@ public class MainApplication extends NavigationApplication { packages.add(new LyricPackage()); packages.add(new UtilsPackage()); packages.add(new CryptoPackage()); + packages.add(new UserApiPackage()); return packages; } diff --git a/android/app/src/main/java/cn/toside/music/mobile/userApi/Console.java b/android/app/src/main/java/cn/toside/music/mobile/userApi/Console.java new file mode 100644 index 0000000..90cd4b0 --- /dev/null +++ b/android/app/src/main/java/cn/toside/music/mobile/userApi/Console.java @@ -0,0 +1,43 @@ +package cn.toside.music.mobile.userApi; + +import android.os.Handler; +import android.os.Message; +import android.util.Log; + +import com.whl.quickjs.wrapper.QuickJSContext; + +public class Console implements QuickJSContext.Console { + private final Handler eventHandler; + + Console(Handler eventHandler) { + this.eventHandler = eventHandler; + } + + private void sendLog(String type, String log) { + Message message = this.eventHandler.obtainMessage(); + message.what = 1001; + message.obj = new Object[]{type, log}; + Log.d("UserApi Log", "[" + type + "]" + log); + this.eventHandler.sendMessage(message); + } + + @Override + public void log(String info) { + sendLog("log", info); + } + + @Override + public void info(String info) { + sendLog("info", info); + } + + @Override + public void warn(String info) { + sendLog("warn", info); + } + + @Override + public void error(String info) { + sendLog("error", info); + } +} diff --git a/android/app/src/main/java/cn/toside/music/mobile/userApi/HandlerWhat.java b/android/app/src/main/java/cn/toside/music/mobile/userApi/HandlerWhat.java new file mode 100644 index 0000000..d6afc7c --- /dev/null +++ b/android/app/src/main/java/cn/toside/music/mobile/userApi/HandlerWhat.java @@ -0,0 +1,9 @@ +package cn.toside.music.mobile.userApi; + +public class HandlerWhat { + public static final int ACTION = 1000; + public static final int INIT = 99; + public static final int INIT_FAILED = 500; + public static final int INIT_SUCCESS = 200; + public static final int LOG = 1001; +} diff --git a/android/app/src/main/java/cn/toside/music/mobile/userApi/JavaScriptThread.java b/android/app/src/main/java/cn/toside/music/mobile/userApi/JavaScriptThread.java new file mode 100644 index 0000000..77fe484 --- /dev/null +++ b/android/app/src/main/java/cn/toside/music/mobile/userApi/JavaScriptThread.java @@ -0,0 +1,63 @@ +package cn.toside.music.mobile.userApi; + +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Message; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.ReactApplicationContext; + +public class JavaScriptThread extends HandlerThread { + private Handler handler; + private QuickJS javaScriptExecutor; + private final ReactApplicationContext reactContext; + private final Bundle scriptInfo; + + JavaScriptThread(ReactApplicationContext context, Bundle info) { + super("JavaScriptThread"); + this.reactContext = context; + this.scriptInfo = info; + } + + public void prepareHandler(final Handler mainHandler) { + start(); + Log.d("UserApi [thread]", "running 2"); + this.handler = new Handler(getLooper()) { + @Override + public void handleMessage(@NonNull Message message) { + if (javaScriptExecutor == null) { + javaScriptExecutor = new QuickJS(reactContext, mainHandler); + Log.d("UserApi [thread]", "javaScript executor created"); + String result = javaScriptExecutor.loadScript(scriptInfo); + if ("".equals(result)) { + Log.d("UserApi [thread]", "script loaded"); + mainHandler.sendEmptyMessage(HandlerWhat.INIT_SUCCESS); + } else { + Log.w("UserApi [thread]", "script load failed: " + result); + Handler handler = mainHandler; + handler.sendMessage(handler.obtainMessage(HandlerWhat.INIT_FAILED, result)); + } + } + if (message.what == HandlerWhat.INIT) return; + if (message.what == HandlerWhat.ACTION) { + Object[] data = (Object[]) message.obj; + Log.d("UserApi [handler]", "handler action: " + data[0]); + javaScriptExecutor.callJS((String) data[0], data[1]); + return; + } + Log.w("UserApi [handler]", "Unknown message what: " + message.what); + } + }; + } + + public Handler getHandler() { + return this.handler; + } + + public void stopThread() { + quit(); + } +} diff --git a/android/app/src/main/java/cn/toside/music/mobile/userApi/JsHandler.java b/android/app/src/main/java/cn/toside/music/mobile/userApi/JsHandler.java new file mode 100644 index 0000000..46dbb03 --- /dev/null +++ b/android/app/src/main/java/cn/toside/music/mobile/userApi/JsHandler.java @@ -0,0 +1,69 @@ +package cn.toside.music.mobile.userApi; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Log; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; + +import java.util.Objects; + +public class JsHandler extends Handler { + private final UtilsEvent utilsEvent; + + JsHandler(Looper looper, UtilsEvent utilsEvent) { + super(looper); + this.utilsEvent = utilsEvent; + } + + private void sendInitFailedEvent(String errorMessage) { + WritableMap params = Arguments.createMap(); + params.putString("action", "init"); + params.putString("data", "{ \"info\": null, \"status\": false, \"errorMessage\": \"Create JavaScript Env Failed\" }"); + UtilsEvent utilsEvent = this.utilsEvent; + Objects.requireNonNull(utilsEvent); + utilsEvent.sendEvent("api-action", params); + sendLogEvent(new Object[]{"error", errorMessage}); + } + + private void sendLogEvent(Object[] data) { + WritableMap params = Arguments.createMap(); + params.putString("action", "log"); + params.putString("type", (String) data[0]); + params.putString("log", (String) data[1]); + UtilsEvent utilsEvent = this.utilsEvent; + Objects.requireNonNull(utilsEvent); + utilsEvent.sendEvent("api-action", params); + } + + private void sendActionEvent(String action, String data) { + WritableMap params = Arguments.createMap(); + params.putString("action", action); + params.putString("data", data); + UtilsEvent utilsEvent = this.utilsEvent; + Objects.requireNonNull(utilsEvent); + utilsEvent.sendEvent("api-action", params); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case HandlerWhat.INIT_SUCCESS: break; + case HandlerWhat.INIT_FAILED: + sendInitFailedEvent((String) msg.obj); + break; + case HandlerWhat.ACTION: + Object[] action = (Object[]) msg.obj; + sendActionEvent((String) action[0], (String) action[1]); + Log.d("UserApi [api call]", "action: " + action[0]); + break; + case HandlerWhat.LOG: + sendLogEvent((Object[]) msg.obj); + break; + default: + Log.w("UserApi [api call]", "Unknown message what: " + msg.what); + break; + } + } +} diff --git a/android/app/src/main/java/cn/toside/music/mobile/userApi/QuickJS.java b/android/app/src/main/java/cn/toside/music/mobile/userApi/QuickJS.java new file mode 100644 index 0000000..6230902 --- /dev/null +++ b/android/app/src/main/java/cn/toside/music/mobile/userApi/QuickJS.java @@ -0,0 +1,188 @@ +package cn.toside.music.mobile.userApi; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.util.Base64; +import android.util.Log; +import cn.toside.music.mobile.crypto.AES; +import cn.toside.music.mobile.crypto.RSA; +import com.facebook.react.bridge.ReactApplicationContext; + +import com.whl.quickjs.android.QuickJSLoader; +import com.whl.quickjs.wrapper.JSCallFunction; +import com.whl.quickjs.wrapper.QuickJSContext; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.UUID; +import okhttp3.HttpUrl; + +public class QuickJS { + private final Handler eventHandler; + private String key; + private final ReactApplicationContext reactContext; + private boolean isInited = false; + private QuickJSContext jsContext = null; + final Handler timeoutHandler = new Handler(); + + public QuickJS(ReactApplicationContext context, Handler eventHandler) { + this.reactContext = context; + this.eventHandler = eventHandler; + } + + private void init() { + if (this.isInited) return; + QuickJSLoader.init(); + this.key = UUID.randomUUID().toString(); + this.isInited = true; + } + + private String getPreloadScript() { + try { + InputStream inputStream = this.reactContext.getAssets().open("script/user-api-preload.js"); + byte[] buffer = new byte[inputStream.available()]; + inputStream.read(buffer); + inputStream.close(); + return new String(buffer, StandardCharsets.UTF_8); + } catch (Exception e) { + return null; + } + } + + private void createEnvObj(QuickJSContext jsContext) { + jsContext.getGlobalObject().setProperty("__lx_native_call__", args -> { + if (this.key.equals(args[0])) { + callNative((String) args[1], (String) args[2]); + return null; + } + return null; + }); + jsContext.getGlobalObject().setProperty("__lx_native_call__utils_str2b64", args -> { + try { + return new String(Base64.encode(((String) args[0]).getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP)); + } catch (Exception e) { + Log.e("UserApi [utils]", "utils_str2b64 error: " + e.getMessage()); + return ""; + } + }); + jsContext.getGlobalObject().setProperty("__lx_native_call__utils_b642buf", args -> { + try { + byte[] byteArray = Base64.decode(((String) args[0]).getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + StringBuilder jsonArrayString = new StringBuilder("["); + for (int i = 0; i < byteArray.length; i++) { + jsonArrayString.append((int) byteArray[i]); + if (i < byteArray.length - 1) { + jsonArrayString.append(","); + } + } + jsonArrayString.append("]"); + return jsonArrayString.toString(); + } catch (Exception e) { + Log.e("UserApi [utils]", "utils_b642buf error: " + e.getMessage()); + return ""; + } + }); + jsContext.getGlobalObject().setProperty("__lx_native_call__utils_str2md5", args -> { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] inputBytes = ((String) args[0]).getBytes(); + byte[] md5Bytes = md.digest(inputBytes); + StringBuilder md5String = new StringBuilder(); + for (byte b : md5Bytes) { + md5String.append(String.format("%02x", b)); + } + return md5String.toString(); + } catch (NoSuchAlgorithmException e) { + Log.e("UserApi [utils]", "utils_str2md5 error: " + e.getMessage()); + return ""; + } + }); + jsContext.getGlobalObject().setProperty("__lx_native_call__utils_aes_encrypt", args -> { + try { + return AES.encrypt((String) args[0], (String) args[1], (String) args[2], (String) args[3]); + } catch (Exception e) { + Log.e("UserApi [utils]", "utils_aes_encrypt error: " + e.getMessage()); + return ""; + } + }); + jsContext.getGlobalObject().setProperty("__lx_native_call__utils_rsa_encrypt", args -> { + try { + return RSA.encryptRSAToString((String) args[0], (String) args[1], (String) args[2]); + } catch (Exception e) { + Log.e("UserApi [utils]", "utils_rsa_encrypt error: " + e.getMessage()); + return ""; + } + }); + jsContext.getGlobalObject().setProperty("__lx_native_call__set_timeout", args -> { + this.timeoutHandler.postDelayed(() -> { + callJS("__set_timeout__", args[0]); + }, (int) args[1]); + return null; + }); + } + + private boolean createJSEnv(String id, String name, String desc, String rawScript) { + init(); + QuickJSContext quickJSContext = this.jsContext; + if (quickJSContext != null) quickJSContext.destroy(); + QuickJSContext create = QuickJSContext.create(); + this.jsContext = create; + create.setConsole(new Console(this.eventHandler)); + String preloadScript = getPreloadScript(); + if (preloadScript == null) return false; + createEnvObj(this.jsContext); + this.jsContext.evaluate(preloadScript); + this.jsContext.getGlobalObject().getJSFunction("lx_setup").call(this.key, id, name, desc, rawScript); + return true; + } + + private void callNative(String action, String data) { + Message message = this.eventHandler.obtainMessage(); + message.what = HandlerWhat.ACTION; + message.obj = new Object[]{action, data}; + Log.d("UserApi [script call]", "script call action: " + action + " data: " + data); + this.eventHandler.sendMessage(message); + } + + public String loadScript(Bundle scriptInfo) { + Log.d("UserApi", "UserApi Thread id: " + Thread.currentThread().getId()); + String script = scriptInfo.getString("script", ""); + if (createJSEnv(scriptInfo.getString("id", ""), + scriptInfo.getString("name", "Unknown"), + scriptInfo.getString("description", ""), script)) { + try { + this.jsContext.evaluate(script); + return ""; + } catch (Exception e) { + Log.e("UserApi", "load script error: " + e.getMessage()); + return e.getMessage(); + } + } + return "create JavaScript Env failed"; + } + + public Object callJS(String action, Object... args) { + Object[] params; + if (args == null) { + params = new Object[]{this.key, action}; + } else { + Object[] params2 = new Object[args.length + 2]; + params2[0] = this.key; + params2[1] = action; + System.arraycopy(args, 0, params2, 2, args.length); + params = params2; + } + try { + return this.jsContext.getGlobalObject().getJSFunction("__lx_native__").call(params); + } catch (Exception e) { + Message message = eventHandler.obtainMessage(); + message.what = HandlerWhat.LOG; + message.obj = new Object[]{"error", "Call script error: " + e.getMessage()}; + eventHandler.sendMessage(message); + Log.e("UserApi", "Call script error: " + e.getMessage()); + return null; + } + } +} diff --git a/android/app/src/main/java/cn/toside/music/mobile/userApi/UserApiModule.java b/android/app/src/main/java/cn/toside/music/mobile/userApi/UserApiModule.java new file mode 100644 index 0000000..37bf1a3 --- /dev/null +++ b/android/app/src/main/java/cn/toside/music/mobile/userApi/UserApiModule.java @@ -0,0 +1,69 @@ +package cn.toside.music.mobile.userApi; + +import android.os.Bundle; +import android.os.Message; +import android.util.Log; +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableMap; +import java.lang.Thread; + +public class UserApiModule extends ReactContextBaseJavaModule { + private JavaScriptThread javaScriptThread; + private final ReactApplicationContext reactContext; + private UtilsEvent utilsEvent; + + UserApiModule(ReactApplicationContext reactContext) { + super(reactContext); + this.javaScriptThread = null; + this.utilsEvent = null; + this.reactContext = reactContext; + } + + @Override + public String getName() { + return "UserApiModule"; + } + + @ReactMethod + public void loadScript(ReadableMap data) { + if (this.utilsEvent == null) this.utilsEvent = new UtilsEvent(this.reactContext); + if (this.javaScriptThread != null) destroy(); + Bundle info = Arguments.toBundle(data); + this.javaScriptThread = new JavaScriptThread(this.reactContext, info); + final JsHandler jsHandler = new JsHandler(this.reactContext.getMainLooper(), this.utilsEvent); + this.javaScriptThread.prepareHandler(jsHandler); + this.javaScriptThread.getHandler().sendEmptyMessage(HandlerWhat.INIT); + this.javaScriptThread.setUncaughtExceptionHandler((thread, ex) -> { + Message message = jsHandler.obtainMessage(); + message.what = HandlerWhat.LOG; + message.obj = new Object[]{"error", "Uncaught exception in JavaScriptThread: " + ex.getMessage()}; + jsHandler.sendMessage(message); + Log.e("JavaScriptThread", "Uncaught exception in JavaScriptThread: " + ex.getMessage()); + }); + Log.d("UserApi", "Module Thread id: " + Thread.currentThread().getId()); + } + + @ReactMethod + public boolean sendAction(String action, String info) { + JavaScriptThread javaScriptThread = this.javaScriptThread; + if (javaScriptThread == null) return false; + Message message = javaScriptThread.getHandler().obtainMessage(); + message.what = HandlerWhat.ACTION; + message.obj = new Object[]{action, info}; + this.javaScriptThread.getHandler().sendMessage(message); + return true; + } + + @ReactMethod + public void destroy() { + JavaScriptThread javaScriptThread = this.javaScriptThread; + if (javaScriptThread == null) { + return; + } + javaScriptThread.stopThread(); + this.javaScriptThread = null; + } +} diff --git a/android/app/src/main/java/cn/toside/music/mobile/userApi/UserApiPackage.java b/android/app/src/main/java/cn/toside/music/mobile/userApi/UserApiPackage.java new file mode 100644 index 0000000..3944092 --- /dev/null +++ b/android/app/src/main/java/cn/toside/music/mobile/userApi/UserApiPackage.java @@ -0,0 +1,21 @@ +package cn.toside.music.mobile.userApi; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class UserApiPackage implements ReactPackage { + @Override + public List createViewManagers(ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Override + public List createNativeModules(ReactApplicationContext reactContext) { + return Arrays.asList(new UserApiModule(reactContext)); + } +} diff --git a/android/app/src/main/java/cn/toside/music/mobile/userApi/UtilsEvent.java b/android/app/src/main/java/cn/toside/music/mobile/userApi/UtilsEvent.java new file mode 100644 index 0000000..cfa3558 --- /dev/null +++ b/android/app/src/main/java/cn/toside/music/mobile/userApi/UtilsEvent.java @@ -0,0 +1,24 @@ +package cn.toside.music.mobile.userApi; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.modules.core.DeviceEventManagerModule; + +public class UtilsEvent { + final String API_ACTION = "api-action"; + private final ReactApplicationContext reactContext; + + UtilsEvent(ReactApplicationContext reactContext) { + this.reactContext = reactContext; + } + + public void sendEvent(String eventName, @Nullable WritableMap params) { + reactContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) + .emit(eventName, params); + } +} diff --git a/publish/changeLog.md b/publish/changeLog.md index 80a6d6a..37c0474 100644 --- a/publish/changeLog.md +++ b/publish/changeLog.md @@ -1,3 +1,7 @@ +### 新增 + +- 新增自定义源,但需要注意的是,移动端自定义源的环境与PC端不同,某些API不可用 + ### 优化 - 添加是否忽略电池优化检查,用于提醒用户添加白名单,确保APP后台播放稳定性 diff --git a/src/components/common/CheckBox/Checkbox.tsx b/src/components/common/CheckBox/Checkbox.tsx index 2392586..35d4006 100644 --- a/src/components/common/CheckBox/Checkbox.tsx +++ b/src/components/common/CheckBox/Checkbox.tsx @@ -25,6 +25,8 @@ export interface Props { */ onPress?: (e: GestureResponderEvent) => void + size?: number + /** * Custom color for checkbox. */ @@ -45,6 +47,7 @@ const PADDING = scaleSizeW(4) const Checkbox = ({ status, disabled, + size = 1, onPress, tintColors, ...rest @@ -86,12 +89,12 @@ const Checkbox = ({ accessibilityRole="checkbox" accessibilityState={{ disabled, checked }} accessibilityLiveRegion="polite" - style={{ ...styles.container, padding: PADDING }} + style={{ ...styles.container, padding: PADDING, marginLeft: -PADDING }} > @@ -99,7 +102,7 @@ const Checkbox = ({ diff --git a/src/components/common/CheckBox/index.tsx b/src/components/common/CheckBox/index.tsx index 8104a2e..d3b9093 100644 --- a/src/components/common/CheckBox/index.tsx +++ b/src/components/common/CheckBox/index.tsx @@ -15,6 +15,7 @@ export interface CheckBoxProps { onChange: (check: boolean) => void disabled?: boolean need?: boolean + size?: number marginRight?: number marginBottom?: number @@ -22,7 +23,7 @@ export interface CheckBoxProps { helpDesc?: string } -export default ({ check, label, children, onChange, helpTitle, helpDesc, disabled = false, need = false, marginRight = 0, marginBottom = 0 }: CheckBoxProps) => { +export default ({ check, label, children, onChange, helpTitle, helpDesc, disabled = false, need = false, marginRight = 0, marginBottom = 0, size = 1 }: CheckBoxProps) => { const theme = useTheme() const [isDisabled, setDisabled] = useState(false) const tintColors = { @@ -65,10 +66,10 @@ export default ({ check, label, children, onChange, helpTitle, helpDesc, disable } return (helpTitle ?? helpDesc) ? ( - + ) : null - }, [helpTitle, helpDesc]) + }, [helpTitle, helpDesc, size]) const contentStyle = { ...styles.content, marginBottom: scaleSizeH(marginBottom) } @@ -78,16 +79,16 @@ export default ({ check, label, children, onChange, helpTitle, helpDesc, disable disabled ? ( - - {label ? {label} : children} + + {label ? {label} : children} {helpComponent} ) : ( - + - {label ? {label} : children} + {label ? {label} : children} {helpComponent} diff --git a/src/config/constant.ts b/src/config/constant.ts index e1572be..6a9fc99 100644 --- a/src/config/constant.ts +++ b/src/config/constant.ts @@ -73,6 +73,8 @@ export const storageDataPrefix = { cheatTip: '@cheat_tip', dislikeList: '@dislike_list', + + userApi: '@user_api__', } as const // v0.x.x 版本的 data keys @@ -105,6 +107,7 @@ export const NAV_MENUS = [ export type NAV_ID_Type = typeof NAV_MENUS[number]['id'] export const LXM_FILE_EXT_RXP = /\.(json|lxmc)$/ +export const USER_API_SOURCE_FILE_EXT_RXP = /\.(js)$/ export const MUSIC_TOGGLE_MODE = { listLoop: 'listLoop', // 列表循环 diff --git a/src/config/globalData.ts b/src/config/globalData.ts index 68d52a7..45a16f5 100644 --- a/src/config/globalData.ts +++ b/src/config/globalData.ts @@ -45,12 +45,14 @@ global.lx = { // syncKeyInfo: {}, isEnableSyncLog: false, + isEnableUserApiLog: false, playerTrackId: '', gettingUrlId: '', qualityList: {}, + apis: {}, jumpMyListPosition: false, diff --git a/src/core/apiSource.ts b/src/core/apiSource.ts index eb9ec06..44d06ac 100644 --- a/src/core/apiSource.ts +++ b/src/core/apiSource.ts @@ -3,26 +3,22 @@ import musicSdk from '@/utils/musicSdk' // import apiSourceInfo from '@renderer/utils/musicSdk/api-source-info' import { updateSetting } from './common' import settingState from '@/store/setting/state' +import { destroyUserApi, setUserApi } from './userApi' +import apiSourceInfo from '@/utils/musicSdk/api-source-info' -export const setUserApi = (apiId: string) => { +export const setApiSource = (apiId: string) => { if (/^user_api/.test(apiId)) { - // qualityList.value = {} - // userApi.status = false - // userApi.message = 'initing' - - // await setUserApiAction(apiId).then(() => { - // apiSource.value = apiId - // }).catch(err => { - // console.log(err) - // let api = apiSourceInfo.find(api => !api.disabled) - // if (!api) return - // apiSource.value = api.id - // if (api.id != appSetting['common.apiSource']) setApiSource(api.id) - // }) + setUserApi(apiId).catch(err => { + console.log(err) + let api = apiSourceInfo.find(api => !api.disabled) + if (!api) return + if (api.id != settingState.setting['common.apiSource']) setApiSource(api.id) + }) } else { // @ts-expect-error global.lx.qualityList = musicSdk.supportQuality[apiId] ?? {} + destroyUserApi() // apiSource.value = apiId // void setUserApiAction(apiId) } diff --git a/src/core/init/index.ts b/src/core/init/index.ts index 036dba7..3c10b33 100644 --- a/src/core/init/index.ts +++ b/src/core/init/index.ts @@ -2,11 +2,12 @@ import { initSetting, showPactModal } from '@/core/common' import registerPlaybackService from '@/plugins/player/service' import initTheme from './theme' import initI18n from './i18n' +import initUserApi from './userApi' import initPlayer from './player' import dataInit from './dataInit' import initSync from './sync' // import syncSetting from './syncSetting' -import { setUserApi } from '@/core/apiSource' +import { setApiSource } from '@/core/apiSource' import commonActions from '@/store/common/action' import settingState from '@/store/setting/state' import { checkUpdate } from '@/core/version' @@ -42,7 +43,10 @@ export default async() => { await initI18n(setting) bootLog('I18n inited.') - setUserApi(setting['common.apiSource']) + await initUserApi(setting) + bootLog('User Api inited.') + + setApiSource(setting['common.apiSource']) bootLog('Api inited.') registerPlaybackService() diff --git a/src/core/init/userApi.ts b/src/core/init/userApi.ts new file mode 100644 index 0000000..de6d35c --- /dev/null +++ b/src/core/init/userApi.ts @@ -0,0 +1,172 @@ +import { type InitParams, onScriptAction, sendAction, type ResponseParams, type UpdateInfoParams } from '@/utils/nativeModules/userApi' +import { log, setUserApiList, setUserApiStatus } from '../userApi' +import settingState from '@/store/setting/state' +import BackgroundTimer from 'react-native-background-timer' +import { httpFetch } from '@/utils/request' +import { getUserApiList } from '@/utils/data' + + +export default async(setting: LX.AppSetting) => { + const requestQueue = new Map void, reject: (error: Error) => void, timeout: number }>() + + const cancelRequest = (requestKey: string, message: string) => { + const target = requestQueue.get(requestKey) + if (!target) return + requestQueue.delete(requestKey) + BackgroundTimer.clearTimeout(target.timeout) + target.reject(new Error(message)) + } + const sendUserApiRequest = async(data: LX.UserApi.UserApiRequestParams) => new Promise((resolve, reject) => { + requestQueue.set(data.requestKey, { + resolve, + reject, + timeout: BackgroundTimer.setTimeout(() => { + const target = requestQueue.get(data.requestKey) + if (!target) return + requestQueue.delete(data.requestKey) + target.reject(new Error('request timeout')) + }, 30_000), + }) + sendAction('request', data) + }) + const handleUserApiResponse = ({ status, result, requestKey, errorMessage }: ResponseParams) => { + const target = requestQueue.get(requestKey) + if (!target) return + requestQueue.delete(requestKey) + BackgroundTimer.clearTimeout(target.timeout) + if (status) target.resolve(result) + else target.reject(new Error(errorMessage ?? 'failed')) + } + const handleStateChange = ({ status, errorMessage, info, id }: InitParams) => { + // console.log(status, message, info) + setUserApiStatus(status, errorMessage) + if (!status || !info?.sources || id !== settingState.setting['common.apiSource']) return + + let apis: any = {} + let qualitys: LX.QualityList = {} + for (const [source, { actions, type, qualitys: sourceQualitys }] of Object.entries(info.sources)) { + if (type != 'music') continue + apis[source as LX.Source] = {} + for (const action of actions) { + switch (action) { + case 'musicUrl': + apis[source].getMusicUrl = (songInfo: LX.Music.MusicInfo, type: LX.Quality) => { + const requestKey = `request__${Math.random().toString().substring(2)}` + return { + canceleFn() { + // userApiRequestCancel(requestKey) + }, + promise: sendUserApiRequest({ + requestKey, + data: { + source, + action: 'musicUrl', + info: { + type, + musicInfo: songInfo, + }, + }, + // eslint-disable-next-line @typescript-eslint/promise-function-async + }).then(res => { + // console.log(res) + if (!/^https?:/.test(res.data.url)) return Promise.reject(new Error('Get url failed')) + return { type, url: res.data.url } + }).catch(async err => { + console.log(err.message) + return Promise.reject(err) + }), + } + } + break + + default: + break + } + } + qualitys[source as LX.Source] = sourceQualitys + } + global.lx.qualityList = qualitys + global.lx.apis = apis + global.state_event.apiSourceUpdated(settingState.setting['common.apiSource']) + } + const showUpdateAlert = ({ name, log, updateUrl }: UpdateInfoParams) => { + // if (updateUrl) { + // void dialog({ + // message: `${t('user_api__update_alert', { name })}\n${log}`, + // selection: true, + // showCancel: true, + // confirmButtonText: t('user_api__update_alert_open_url'), + // cancelButtonText: t('close'), + // }).then(confirm => { + // if (!confirm) return + // window.setTimeout(() => { + // void openUrl(updateUrl) + // }, 300) + // }) + // } else { + // void dialog({ + // message: `${t('user_api__update_alert', { name })}\n${log}`, + // selection: true, + // confirmButtonText: t('ok'), + // }) + // } + } + + onScriptAction((event) => { + // console.log('script actuon: ', event) + switch (event.action) { + case 'init': + handleStateChange(event.data) + break + case 'response': + handleUserApiResponse(event.data) + break + case 'request': + httpFetch(event.data.url, { + ...event.data.options, + credentials: 'omit', + cache: 'default', + }).promise.then(response => { + // console.log(response) + sendAction('response', { + error: null, + requestKey: event.data.requestKey, + response, + }) + }).catch(err => { + sendAction('response', { + error: err.message, + requestKey: event.data.requestKey, + response: null, + }) + }) + break + case 'cancelRequest': + cancelRequest(event.data, 'request canceled') + break + case 'showUpdateAlert': + showUpdateAlert(event.data) + break + case 'log': + switch ((event as unknown as { type: keyof typeof log }).type) { + case 'log': + case 'info': + log.info((event as unknown as { log: string }).log) + break + case 'error': + log.error((event as unknown as { log: string }).log) + break + case 'warn': + log.warn((event as unknown as { log: string }).log) + break + default: + break + } + break + default: + break + } + }) + + setUserApiList(await getUserApiList()) +} diff --git a/src/core/userApi.ts b/src/core/userApi.ts new file mode 100644 index 0000000..56b208c --- /dev/null +++ b/src/core/userApi.ts @@ -0,0 +1,69 @@ +import { action, state } from '@/store/userApi' +import { addUserApi, getUserApiScript, removeUserApi as removeUserApiFromStore, setUserApiAllowShowUpdateAlert as setUserApiAllowShowUpdateAlertFromStore } from '@/utils/data' +import { destroy, loadScript } from '@/utils/nativeModules/userApi' +import { log as writeLog } from '@/utils/log' + + +export const setUserApi = async(apiId: string) => { + global.lx.qualityList = {} + setUserApiStatus(false, 'initing') + + const target = state.list.find(api => api.id === apiId) + if (!target) throw new Error('api not found') + const script = await getUserApiScript(target.id) + loadScript({ ...target, script }) +} + +export const destroyUserApi = () => { + destroy() +} + + +export const setUserApiStatus: typeof action['setStatus'] = (status, message) => { + action.setStatus(status, message) +} + +export const setUserApiList: typeof action['setUserApiList'] = (list) => { + action.setUserApiList(list) +} + +export const importUserApi = async(script: string) => { + const info = await addUserApi(script) + action.addUserApi(info) +} + +export const removeUserApi = async(ids: string[]) => { + console.log(ids) + const list = await removeUserApiFromStore(ids) + console.log(list) + action.setUserApiList(list) +} + +export const setUserApiAllowShowUpdateAlert = async(id: string, enable: boolean) => { + await setUserApiAllowShowUpdateAlertFromStore(id, enable) + action.setUserApiAllowShowUpdateAlert(id, enable) +} + +export const log = { + r_info(...params: any[]) { + writeLog.info(...params) + }, + r_warn(...params: any[]) { + writeLog.warn(...params) + }, + r_error(...params: any[]) { + writeLog.error(...params) + }, + log(...params: any[]) { + if (global.lx.isEnableUserApiLog) writeLog.info(...params) + }, + info(...params: any[]) { + if (global.lx.isEnableUserApiLog) writeLog.info(...params) + }, + warn(...params: any[]) { + if (global.lx.isEnableUserApiLog) writeLog.warn(...params) + }, + error(...params: any[]) { + if (global.lx.isEnableUserApiLog) writeLog.error(...params) + }, +} diff --git a/src/lang/en_us.json b/src/lang/en_us.json index 81f8489..88e5b07 100644 --- a/src/lang/en_us.json +++ b/src/lang/en_us.json @@ -12,6 +12,7 @@ "change_position_music_multi_title": "Adjust the position of the selected {num} song to", "change_position_music_title": "Adjust the position of {name} to", "change_position_tip": "Please enter a new position", + "close": "Close", "collect": "Collect", "collect_songlist": "Collection Songlist", "collect_success": "Collection success", @@ -206,9 +207,13 @@ "setting_basic_show_exit_btn": "Show exit app button", "setting_basic_source": "Music source", "setting_basic_source_direct": "Direct API", + "setting_basic_source_status_failed": "initialization failed", + "setting_basic_source_status_initing": "Initializing", + "setting_basic_source_status_success": "Initialization successful", "setting_basic_source_temp": "Temporary API (some features not available; workaround if Test API unavailable)", "setting_basic_source_test": "Test API (Available for most software features)", "setting_basic_source_title": "Choose a music source", + "setting_basic_source_user_api_btn": "Custom source management", "setting_basic_sourcename": "Source name", "setting_basic_sourcename_alias": "Aliases", "setting_basic_sourcename_real": "Original", @@ -260,6 +265,7 @@ "setting_other_log_sync_log": "Record synchronization log", "setting_other_log_tip_clean_success": "Log cleaning completed", "setting_other_log_tip_null": "The log is empty~", + "setting_other_log_user_api_log": "Logging custom source logs", "setting_play_audio_offload": "Enable audio offload", "setting_play_audio_offload_tip": "Enabling audio offloading can save power consumption, but on some devices, all songs may prompt \"Audio loading error\" or \"The whole song cannot be played completely\". This is caused by a bug in the current system.\nFor People who encounter this problem can turn off this option and restart the application completely to try again.", "setting_play_auto_clean_played_list": "Automatically clear the played list", @@ -382,6 +388,14 @@ "timeout_exit_tip_on": "Stop playing after {time}", "toggle_source_failed": "Failed to change the source, please try to manually search for the song in other sources to play", "toggle_source_try": "Try switching to another source...", + "user_api_allow_show_update_alert": "Allow update popups to be displayed", + "user_api_btn_import": "Import", + "user_api_import_desc": "Select custom source file", + "user_api_import_failed_tip": "Import failed", + "user_api_note": "Tip: Although we have isolated the running environment of the script as much as possible, importing scripts containing malicious behaviors may still affect your system, so please import with caution.", + "user_api_readme": "Source writing instructions: ", + "user_api_remove_tip": "Do you really want to remove {name}?", + "user_api_title": "Custom source management", "version_btn_close": "Close", "version_btn_downloading": "I am trying to download...{total}/{current} ({progress}%)", "version_btn_failed": "Retry", diff --git a/src/lang/zh_cn.json b/src/lang/zh_cn.json index 2998177..9b9be87 100644 --- a/src/lang/zh_cn.json +++ b/src/lang/zh_cn.json @@ -12,6 +12,7 @@ "change_position_music_multi_title": "将已选的 {num} 首歌曲的位置调整到", "change_position_music_title": "将 {name} 的位置调整到", "change_position_tip": "请输入新的位置", + "close": "关闭", "collect": "收藏", "collect_songlist": "收藏歌单", "collect_success": "收藏成功", @@ -206,9 +207,13 @@ "setting_basic_show_exit_btn": "显示退出应用按钮", "setting_basic_source": "音乐来源", "setting_basic_source_direct": "试听接口(这是最后的选择...)", + "setting_basic_source_status_failed": "初始化失败", + "setting_basic_source_status_initing": "初始化中", + "setting_basic_source_status_success": "初始化成功", "setting_basic_source_temp": "临时接口(软件的某些功能不可用,建议测试接口不可用再使用本接口)", "setting_basic_source_test": "测试接口(几乎软件的所有功能都可用)", "setting_basic_source_title": "选择音乐来源", + "setting_basic_source_user_api_btn": "自定义源管理", "setting_basic_sourcename": "音源名字", "setting_basic_sourcename_alias": "别名", "setting_basic_sourcename_real": "原名", @@ -260,6 +265,7 @@ "setting_other_log_sync_log": "记录同步日志", "setting_other_log_tip_clean_success": "日志清理完成", "setting_other_log_tip_null": "日志是空的哦~", + "setting_other_log_user_api_log": "记录自定义源日志", "setting_play_audio_offload": "启用音频卸载", "setting_play_audio_offload_tip": "启用音频卸载可以节省耗电量,但在某些设备上可能会出现所有歌曲都提示 「音频加载出错」 或者 「无法完整播放整首歌」 的问题,这是由于当前系统的bug导致的。\n对于遇到这个问题的人可以关闭此选项后完全重启应用再试。", "setting_play_auto_clean_played_list": "自动清空已播放列表", @@ -382,6 +388,14 @@ "timeout_exit_tip_on": "{time} 后停止播放", "toggle_source_failed": "换源失败,请尝试手动在其他源搜索该歌曲播放", "toggle_source_try": "尝试切换到其他源...", + "user_api_allow_show_update_alert": "允许显示更新弹窗", + "user_api_btn_import": "导入", + "user_api_import_desc": "选择自定义源文件", + "user_api_import_failed_tip": "导入失败", + "user_api_note": "提示:虽然我们已经尽可能地隔离了脚本的运行环境,但导入包含恶意行为的脚本仍可能会影响你的系统,请谨慎导入。", + "user_api_readme": "源编写说明:", + "user_api_remove_tip": "你真的要移除 {name} 吗?", + "user_api_title": "自定义源管理", "version_btn_close": "关闭", "version_btn_downloading": "正在努力下载中...{total}/{current} ({progress}%)", "version_btn_failed": "重试", diff --git a/src/screens/Home/Views/Setting/settings/Basic/Source.tsx b/src/screens/Home/Views/Setting/settings/Basic/Source.tsx index 97b171a..8b386a3 100644 --- a/src/screens/Home/Views/Setting/settings/Basic/Source.tsx +++ b/src/screens/Home/Views/Setting/settings/Basic/Source.tsx @@ -1,14 +1,18 @@ -import { memo, useCallback, useMemo } from 'react' +import { memo, useCallback, useMemo, useRef } from 'react' import { View } from 'react-native' import SubTitle from '../../components/SubTitle' import CheckBox from '@/components/common/CheckBox' import { createStyle } from '@/utils/tools' -import { setUserApi } from '@/core/apiSource' +import { setApiSource } from '@/core/apiSource' import { useI18n } from '@/lang' import apiSourceInfo from '@/utils/musicSdk/api-source-info' import { useSettingValue } from '@/store/setting/hook' +import { useStatus, useUserApiList } from '@/store/userApi' +import Button from '../../components/Button' +import UserApiEditModal, { type UserApiEditModalType } from './UserApiEditModal' +// import { importUserApi, removeUserApi } from '@/core/userApi' const apiSourceList = apiSourceInfo.map(api => ({ id: api.id, @@ -40,8 +44,35 @@ export default memo(() => { id: s.id, })), [t]) const setApiSourceId = useCallback((id: string) => { - setUserApi(id) + setApiSource(id) }, []) + const userApiListRaw = useUserApiList() + const apiStatus = useStatus() + const apiSourceSetting = useSettingValue('common.apiSource') + const userApiList = useMemo(() => { + const getApiStatus = () => { + let status + if (apiStatus.status) status = t('setting_basic_source_status_success') + else if (apiStatus.message == 'initing') status = t('setting_basic_source_status_initing') + else status = t('setting_basic_source_status_failed') + + return status + } + return userApiListRaw.map(api => { + return { + id: api.id, + name: `${api.name}${api.description ? `(${api.description})` : ''}${api.id == apiSourceSetting ? `[${getApiStatus()}]` : ''}`, + // status: apiStatus.status, + // message: apiStatus.message, + // disabled: false, + } + }) + }, [userApiListRaw, apiStatus, apiSourceSetting, t]) + + const modalRef = useRef(null) + const handleShow = () => { + modalRef.current?.show() + } return ( @@ -49,7 +80,14 @@ export default memo(() => { { list.map(({ id, name }) => ) } + { + userApiList.map(({ id, name }) => ) + } + + + + ) }) @@ -61,4 +99,8 @@ const styles = createStyle({ // flexDirection: 'row', // flexWrap: 'wrap', }, + btn: { + marginTop: 10, + flexDirection: 'row', + }, }) diff --git a/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/List.tsx b/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/List.tsx new file mode 100644 index 0000000..71e1a10 --- /dev/null +++ b/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/List.tsx @@ -0,0 +1,119 @@ +import { useCallback } from 'react' +import Text from '@/components/common/Text' +import { View, TouchableOpacity, ScrollView } from 'react-native' +import { confirmDialog, createStyle } from '@/utils/tools' +import { useTheme } from '@/store/theme/hook' +import { useI18n } from '@/lang' +import { useUserApiList } from '@/store/userApi' +import { useSettingValue } from '@/store/setting/hook' +import { removeUserApi, setUserApiAllowShowUpdateAlert } from '@/core/userApi' +import { BorderRadius } from '@/theme' +import CheckBox from '@/components/common/CheckBox' +import { Icon } from '@/components/common/Icon' + + +const ListItem = ({ item, activeId, onRemove, onChangeAllowShowUpdateAlert }: { + item: LX.UserApi.UserApiInfo + activeId: string + onRemove: (id: string, name: string) => void + onChangeAllowShowUpdateAlert: (id: string, enabled: boolean) => void +}) => { + const theme = useTheme() + const t = useI18n() + const changeAllowShowUpdateAlert = (check: boolean) => { + onChangeAllowShowUpdateAlert(item.id, check) + } + const handleRemove = () => { + onRemove(item.id, item.name) + } + + return ( + + + {item.name} + {item.description} + + + + + + + + + ) +} + +export interface UserApiEditModalProps { + onSave: (rules: string) => void + // onSourceChange: SourceSelectorProps['onSourceChange'] +} +export interface UserApiEditModalType { + show: (rules: string) => void +} + + +export default () => { + const userApiList = useUserApiList() + const apiSource = useSettingValue('common.apiSource') + + const handleRemove = useCallback(async(id: string, name: string) => { + const confirm = await confirmDialog({ + message: global.i18n.t('user_api_remove_tip', { name }), + cancelButtonText: global.i18n.t('cancel_button_text_2'), + confirmButtonText: global.i18n.t('confirm_button_text'), + bgClose: false, + }) + if (!confirm) return + void removeUserApi([id]) + }, []) + const handleChangeAllowShowUpdateAlert = useCallback((id: string, enabled: boolean) => { + void setUserApiAllowShowUpdateAlert(id, enabled) + }, []) + + return ( + + true}> + { + userApiList.map((item) => { + return ( + + ) + }) + } + + + ) +} + + +const styles = createStyle({ + scrollView: { + flexGrow: 0, + }, + list: { + paddingBottom: 15, + flexDirection: 'column', + }, + listItem: { + padding: 10, + borderRadius: BorderRadius.normal, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + // btns: { + // padding: 5, + // }, + btn: { + padding: 10, + // backgroundColor: 'rgba(0, 0, 0, 0.2)', + }, +}) + + diff --git a/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/ScriptImportExport.tsx b/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/ScriptImportExport.tsx new file mode 100644 index 0000000..6fa79a6 --- /dev/null +++ b/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/ScriptImportExport.tsx @@ -0,0 +1,98 @@ +import ChoosePath, { type ChoosePathType } from '@/components/common/ChoosePath' +import { USER_API_SOURCE_FILE_EXT_RXP } from '@/config/constant' +import { forwardRef, useImperativeHandle, useRef, useState } from 'react' +import { handleImport } from './action' + +export interface SelectInfo { + // listInfo: LX.List.MyListInfo + // selectedList: LX.Music.MusicInfo[] + // index: number + // listId: string + // single: boolean + action: 'import' +} +const initSelectInfo = {} + +// export interface ScriptImportExportProps { +// // onRename: (listInfo: LX.List.UserListInfo) => void +// // onImport: (index: number) => void +// // onExport: (listInfo: LX.List.MyListInfo) => void +// // onSync: (listInfo: LX.List.UserListInfo) => void +// // onRemove: (listInfo: LX.List.MyListInfo) => void +// } +export interface ScriptImportExportType { + import: () => void + // export: (listInfo: LX.List.MyListInfo, index: number) => void +} + +export default forwardRef((props, ref) => { + const [visible, setVisible] = useState(false) + const choosePathRef = useRef(null) + const selectInfoRef = useRef((initSelectInfo as SelectInfo)) + // console.log('render import export') + + useImperativeHandle(ref, () => ({ + import() { + selectInfoRef.current = { + action: 'import', + } + if (visible) { + choosePathRef.current?.show({ + title: global.i18n.t('user_api_import_desc'), + dirOnly: false, + filter: USER_API_SOURCE_FILE_EXT_RXP, + }) + } else { + setVisible(true) + requestAnimationFrame(() => { + choosePathRef.current?.show({ + title: global.i18n.t('user_api_import_desc'), + dirOnly: false, + filter: USER_API_SOURCE_FILE_EXT_RXP, + }) + }) + } + }, + // export(listInfo, index) { + // selectInfoRef.current = { + // action: 'export', + // listInfo, + // index, + // } + // if (visible) { + // choosePathRef.current?.show({ + // title: global.i18n.t('list_export_part_desc'), + // dirOnly: true, + // filter: LXM_FILE_EXT_RXP, + // }) + // } else { + // setVisible(true) + // requestAnimationFrame(() => { + // choosePathRef.current?.show({ + // title: global.i18n.t('list_export_part_desc'), + // dirOnly: true, + // filter: LXM_FILE_EXT_RXP, + // }) + // }) + // } + // }, + })) + + + const onConfirmPath = (path: string) => { + switch (selectInfoRef.current.action) { + case 'import': + handleImport(path) + break + // case 'export': + // handleExport(selectInfoRef.current.listInfo, path) + // break + } + } + + return ( + visible + ? + : null + ) +}) diff --git a/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/action.ts b/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/action.ts new file mode 100644 index 0000000..871477d --- /dev/null +++ b/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/action.ts @@ -0,0 +1,17 @@ +import { importUserApi } from '@/core/userApi' +import { log } from '@/utils/log' +import { readFile } from '@/utils/nativeModules/utils' +import { toast } from '@/utils/tools' + + +export const handleImport = (path: string) => { + // toast(global.i18n.t('setting_backup_part_import_list_tip_unzip')) + void readFile(path).then(async script => { + if (script == null) throw new Error('Read file failed') + await importUserApi(script) + }).catch((error: any) => { + log.error(error.stack) + toast(global.i18n.t('user_api_import_failed_tip')) + }) +} + diff --git a/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/index.tsx b/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/index.tsx new file mode 100644 index 0000000..27e5765 --- /dev/null +++ b/src/screens/Home/Views/Setting/settings/Basic/UserApiEditModal/index.tsx @@ -0,0 +1,182 @@ +import { useRef, useImperativeHandle, forwardRef, useState } from 'react' +import Text from '@/components/common/Text' +import { View, TouchableOpacity } from 'react-native' +import { createStyle, openUrl } from '@/utils/tools' +import { useTheme } from '@/store/theme/hook' +import { useI18n } from '@/lang' +import Dialog, { type DialogType } from '@/components/common/Dialog' +import Button from '@/components/common/Button' +import List from './List' +import ScriptImportExport, { type ScriptImportExportType } from './ScriptImportExport' + +// interface UrlInputType { +// setText: (text: string) => void +// getText: () => string +// focus: () => void +// } +// const UrlInput = forwardRef((props, ref) => { +// const theme = useTheme() +// const t = useI18n() +// const [text, setText] = useState('') +// const inputRef = useRef(null) +// const [height, setHeight] = useState(100) + +// useImperativeHandle(ref, () => ({ +// getText() { +// return text.trim() +// }, +// setText(text) { +// setText(text) +// }, +// focus() { +// inputRef.current?.focus() +// }, +// })) + +// const handleLayout = useCallback(({ nativeEvent }: LayoutChangeEvent) => { +// setHeight(nativeEvent.layout.height) +// }, []) + +// return ( +// +// +// +// ) +// }) + + +// export interface UserApiEditModalProps { +// onSave: (rules: string) => void +// // onSourceChange: SourceSelectorProps['onSourceChange'] +// } +export interface UserApiEditModalType { + show: () => void +} + +export default forwardRef((props, ref) => { + const dialogRef = useRef(null) + const scriptImportExportRef = useRef(null) + // const sourceSelectorRef = useRef(null) + // const inputRef = useRef(null) + const [visible, setVisible] = useState(false) + const theme = useTheme() + const t = useI18n() + + const handleShow = () => { + dialogRef.current?.setVisible(true) + // requestAnimationFrame(() => { + // inputRef.current?.setText('') + // sourceSelectorRef.current?.setSource(source) + // setTimeout(() => { + // inputRef.current?.focus() + // }, 300) + // }) + } + useImperativeHandle(ref, () => ({ + show() { + if (visible) handleShow() + else { + setVisible(true) + requestAnimationFrame(() => { + handleShow() + }) + } + }, + })) + + const handleCancel = () => { + dialogRef.current?.setVisible(false) + } + const handleImport = () => { + scriptImportExportRef.current?.import() + } + const openFAQPage = () => { + void openUrl('https://lyswhut.github.io/lx-music-doc/mobile/custom-source') + } + + return ( + visible + ? ( + + + {/* */} + {t('user_api_title')} + + + + {t('user_api_readme')} + + + FAQ + + + {t('user_api_note')} + + + + + + + + ) : null + ) +}) + + +const styles = createStyle({ + content: { + // flexGrow: 1, + flexShrink: 1, + paddingHorizontal: 15, + paddingTop: 15, + paddingBottom: 10, + flexDirection: 'column', + }, + title: { + marginBottom: 15, + textAlign: 'center', + // backgroundColor: 'rgba(0, 0, 0, 0.2)', + }, + tips: { + marginTop: 15, + flexDirection: 'row', + flexWrap: 'wrap', + }, + tipsText: { + marginTop: 8, + textAlignVertical: 'bottom', + // lineHeight: 18, + // backgroundColor: 'rgba(0, 0, 0, 0.2)', + }, + btns: { + flexDirection: 'row', + justifyContent: 'center', + paddingBottom: 15, + paddingLeft: 15, + // paddingRight: 15, + }, + btn: { + flex: 1, + paddingTop: 8, + paddingBottom: 8, + paddingLeft: 10, + paddingRight: 10, + alignItems: 'center', + borderRadius: 4, + marginRight: 15, + }, +}) + + diff --git a/src/screens/Home/Views/Setting/settings/Other/Log.tsx b/src/screens/Home/Views/Setting/settings/Other/Log.tsx index 1528b52..b4a104e 100644 --- a/src/screens/Home/Views/Setting/settings/Other/Log.tsx +++ b/src/screens/Home/Views/Setting/settings/Other/Log.tsx @@ -17,6 +17,7 @@ export default memo(() => { const [logText, setLogText] = useState('') const isUnmountedRef = useRef(true) const [isEnableSyncErrorLog, setIsEnableSyncErrorLog] = useState(global.lx.isEnableSyncLog) + const [isEnableUserApiLog, setIsEnableUserApiLog] = useState(global.lx.isEnableUserApiLog) const getErrorLog = () => { void getLogs().then(log => { @@ -45,6 +46,11 @@ export default memo(() => { global.lx.isEnableSyncLog = enable } + const handleSetEnableUserApiLog = (enable: boolean) => { + setIsEnableUserApiLog(enable) + global.lx.isEnableUserApiLog = enable + } + useEffect(() => { isUnmountedRef.current = false @@ -58,6 +64,7 @@ export default memo(() => { + diff --git a/src/store/userApi/action.ts b/src/store/userApi/action.ts new file mode 100644 index 0000000..c38f3b7 --- /dev/null +++ b/src/store/userApi/action.ts @@ -0,0 +1,31 @@ +import { state } from './state' +import { event } from './event' + +export const setStatus = (status: LX.UserApi.UserApiStatus['status'], message: LX.UserApi.UserApiStatus['message']) => { + state.status.status = status + state.status.message = message + + event.status_changed({ status, message }) +} + + +export const setUserApiList = (list: LX.UserApi.UserApiInfo[]) => { + state.list = list + + event.list_changed([...list]) +} + +export const addUserApi = (info: LX.UserApi.UserApiInfo) => { + state.list.push(info) + + event.list_changed([...state.list]) +} + + +export const setUserApiAllowShowUpdateAlert = (id: string, enable: boolean) => { + const targetIndex = state.list.findIndex(api => api.id == id) + if (targetIndex < 0) return + state.list.splice(targetIndex, 1, { ...state.list[targetIndex], allowShowUpdateAlert: enable }) + + event.list_changed([...state.list]) +} diff --git a/src/store/userApi/event.ts b/src/store/userApi/event.ts new file mode 100644 index 0000000..2d15d2e --- /dev/null +++ b/src/store/userApi/event.ts @@ -0,0 +1,26 @@ +import Event from '@/event/Event' + + +class UserApiEvent extends Event { + status_changed(status: { status: boolean, message?: string }) { + this.emit('status_changed', status) + } + + list_changed(list: LX.UserApi.UserApiInfo[]) { + this.emit('list_changed', list) + } +} + + +type EventMethods = Omit + + +declare class EventType extends UserApiEvent { + on(event: K, listener: EventMethods[K]): any + off(event: K, listener: EventMethods[K]): any +} + +type UserApiEventTypes = Omit> + + +export const event: UserApiEventTypes = new UserApiEvent() diff --git a/src/store/userApi/hook.ts b/src/store/userApi/hook.ts new file mode 100644 index 0000000..3dbe40d --- /dev/null +++ b/src/store/userApi/hook.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react' +import { state } from './state' +import { event } from './event' + +export const useStatus = () => { + const [value, update] = useState(state.status) + + useEffect(() => { + event.on('status_changed', update) + return () => { + event.off('status_changed', update) + } + }, []) + + return value +} + +export const useUserApiList = () => { + const [value, update] = useState(state.list) + + useEffect(() => { + event.on('list_changed', update) + return () => { + event.off('list_changed', update) + } + }, []) + + return value +} diff --git a/src/store/userApi/index.ts b/src/store/userApi/index.ts new file mode 100644 index 0000000..8abdee4 --- /dev/null +++ b/src/store/userApi/index.ts @@ -0,0 +1,4 @@ + +export * as action from './action' +export * from './state' +export * from './hook' diff --git a/src/store/userApi/state.ts b/src/store/userApi/state.ts new file mode 100644 index 0000000..77053e5 --- /dev/null +++ b/src/store/userApi/state.ts @@ -0,0 +1,22 @@ + +interface InitState { + list: LX.UserApi.UserApiInfo[] + status: { + status: boolean + message?: string + } + apis: Partial +} +const state: InitState = { + list: [], + status: { + status: false, + message: 'initing', + }, + apis: {}, +} + + +export { + state, +} diff --git a/src/types/app.d.ts b/src/types/app.d.ts index a9b4d6a..a5b5301 100644 --- a/src/types/app.d.ts +++ b/src/types/app.d.ts @@ -31,9 +31,11 @@ interface GlobalData { isScreenKeepAwake: boolean isPlayedStop: boolean isEnableSyncLog: boolean + isEnableUserApiLog: boolean playerTrackId: string qualityList: LX.QualityList + apis: Partial jumpMyListPosition: boolean diff --git a/src/types/user_api.d.ts b/src/types/user_api.d.ts new file mode 100644 index 0000000..68a2b73 --- /dev/null +++ b/src/types/user_api.d.ts @@ -0,0 +1,60 @@ +declare namespace LX { + namespace UserApi { + type UserApiSourceInfoType = 'music' + type UserApiSourceInfoActions = 'musicUrl' + + interface UserApiSourceInfo { + name: string + type: UserApiSourceInfoType + actions: UserApiSourceInfoActions[] + qualitys: LX.Quality[] + } + + type UserApiSources = Record + + + interface UserApiInfo { + id: string + name: string + description: string + // script: string + allowShowUpdateAlert: boolean + sources?: UserApiSources + } + + interface UserApiStatus { + status: boolean + message?: string + apiInfo?: UserApiInfo + } + + interface UserApiUpdateInfo { + name: string + description: string + log: string + updateUrl?: string + } + + interface UserApiRequestParams { + requestKey: string + data: any + } + interface UserApiRequestParams { + requestKey: string + data: any + } + type UserApiRequestCancelParams = string + type UserApiSetApiParams = string + + interface UserApiSetAllowUpdateAlertParams { + id: string + enable: boolean + } + + interface ImportUserApi { + apiInfo: UserApiInfo + apiList: UserApiInfo[] + } + + } +} diff --git a/src/utils/data.ts b/src/utils/data.ts index ff98fb4..600d00a 100644 --- a/src/utils/data.ts +++ b/src/utils/data.ts @@ -26,6 +26,7 @@ const syncHostPrefix = storageDataPrefix.syncHost const syncHostHistoryPrefix = storageDataPrefix.syncHostHistory const listPrefix = storageDataPrefix.list const dislikeListPrefix = storageDataPrefix.dislikeList +const userApiPrefix = storageDataPrefix.userApi // const defaultListKey = listPrefix + 'default' // const loveListKey = listPrefix + 'love' @@ -460,3 +461,54 @@ export const removeSyncHostHistory = async(index: number) => { await saveData(syncHostHistoryPrefix, syncHostHistory) } +let userApis: LX.UserApi.UserApiInfo[] = [] +export const getUserApiList = async(): Promise => { + userApis = await getData(userApiPrefix) ?? [] + return [...userApis] +} +export const getUserApiScript = async(id: string): Promise => { + const script = await getData(`${userApiPrefix}${id}`) ?? '' + return script +} +export const addUserApi = async(script: string): Promise => { + let scriptInfo = script.split(/\r?\n/) + let name = scriptInfo[1] || '' + let description = scriptInfo[2] || '' + name = name.startsWith(' * @name ') ? name.replace(' * @name ', '').trim() : `user_api_${new Date().toLocaleString()}` + if (name.length > 24) name = name.substring(0, 24) + '...' + description = description.startsWith(' * @description ') ? description.replace(' * @description ', '').trim() : '' + if (description.length > 36) description = description.substring(0, 36) + '...' + const apiInfo = { + id: `user_api_${Math.random().toString().substring(2, 5)}_${Date.now()}`, + name, + description, + // script, + allowShowUpdateAlert: true, + } + userApis.push(apiInfo) + await saveDataMultiple([ + [userApiPrefix, userApis], + [`${userApiPrefix}${apiInfo.id}`, script], + ]) + return apiInfo +} +export const removeUserApi = async(ids: string[]) => { + if (!userApis) return [] + const _ids: string[] = [] + for (let index = userApis.length - 1; index > -1; index--) { + if (ids.includes(userApis[index].id)) { + _ids.push(`${userApiPrefix}${userApis[index].id}`) + userApis.splice(index, 1) + ids.splice(index, 1) + } + } + await saveData(userApiPrefix, userApis) + if (_ids.length) await removeDataMultiple(_ids) + return [...userApis] +} +export const setUserApiAllowShowUpdateAlert = async(id: string, enable: boolean) => { + const targetApi = userApis?.find(api => api.id == id) + if (!targetApi) return + targetApi.allowShowUpdateAlert = enable + await saveData(userApiPrefix, userApis) +} diff --git a/src/utils/musicSdk/api-source.js b/src/utils/musicSdk/api-source.js index 7e86e8c..6b6711c 100644 --- a/src/utils/musicSdk/api-source.js +++ b/src/utils/musicSdk/api-source.js @@ -47,6 +47,7 @@ for (const api of apiSourceInfo) { const getAPI = source => apiList[`${settingState.setting['common.apiSource']}_api_${source}`] const apis = source => { + if (/^user_api/.test(settingState.setting['common.apiSource'])) return global.lx.apis[source] const api = getAPI(source) if (api) return api throw new Error('Api is not found') diff --git a/src/utils/nativeModules/userApi.ts b/src/utils/nativeModules/userApi.ts new file mode 100644 index 0000000..372a5a5 --- /dev/null +++ b/src/utils/nativeModules/userApi.ts @@ -0,0 +1,89 @@ +import { NativeEventEmitter, NativeModules } from 'react-native' + +const { UserApiModule } = NativeModules + +export const loadScript = (info: LX.UserApi.UserApiInfo & { script: string }) => { + UserApiModule.loadScript({ + id: info.id, + name: info.name, + description: info.description, + script: info.script, + }) +} + +export interface SendResponseParams { + requestKey: string + error: string | null + response: { + statusCode: number + statusMessage: string + headers: Record + body: any + } | null +} +export interface SendActions { + request: LX.UserApi.UserApiRequestParams + response: SendResponseParams +} +export const sendAction = (action: T, data: SendActions[T]) => { + UserApiModule.sendAction(action, JSON.stringify(data)) +} + +// export const clearAppCache = CacheModule.clearAppCache as () => Promise + +export interface InitParams { + id: string + status: boolean + errorMessage: string + info: LX.UserApi.UserApiInfo +} + +export interface ResponseParams { + status: boolean + errorMessage?: string + requestKey: string + result: any +} +export interface UpdateInfoParams { + name: string + log: string + updateUrl: string +} +export interface RequestParams { + requestKey: string + url: string + options: { + method: string + data: any + timeout: number + headers: any + binary: boolean + } +} +export type CancelRequestParams = string + +export interface Actions { + init: InitParams + request: RequestParams + cancelRequest: CancelRequestParams + response: ResponseParams + showUpdateAlert: UpdateInfoParams + log: string +} +export type ActionsEvent = { [K in keyof Actions]: { action: K, data: Actions[K] } }[keyof Actions] + +export const onScriptAction = (callback: (event: ActionsEvent) => void): () => void => { + const eventEmitter = new NativeEventEmitter(UserApiModule) + const eventListener = eventEmitter.addListener('api-action', event => { + if (event.data) event.data = JSON.parse(event.data) + callback(event) + }) + + return () => { + eventListener.remove() + } +} + +export const destroy = () => { + UserApiModule.destroy() +}