diff --git a/.gitignore b/.gitignore index 88edbf9f..901d5221 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,10 @@ node_modules/ .vscode # Built files -dist -dist-renderer-webpack -dist-library-files -dist-extensions +/dist +/dist-renderer-webpack +/dist-library-files +/dist-extensions src-renderer/packager/standalone.html # Debian release scripts create files that shouldn't be committed diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 00000000..3ee131ae --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "org.turbowarp.android" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "org.turbowarp.android" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.activity) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.webkit) + implementation(libs.brotlidec) + implementation(libs.androidx.navigation.compose) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/app/src/androidTest/java/org/turbowarp/android/ExampleInstrumentedTest.kt b/android/app/src/androidTest/java/org/turbowarp/android/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..9ae823a8 --- /dev/null +++ b/android/app/src/androidTest/java/org/turbowarp/android/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package org.turbowarp.android + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.turbowarp.android", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..7cb82ef4 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/assets/dist-extensions b/android/app/src/main/assets/dist-extensions new file mode 120000 index 00000000..40ec5ca3 --- /dev/null +++ b/android/app/src/main/assets/dist-extensions @@ -0,0 +1 @@ +../../../../../dist-extensions/ \ No newline at end of file diff --git a/android/app/src/main/assets/dist-library-files b/android/app/src/main/assets/dist-library-files new file mode 120000 index 00000000..aebd9165 --- /dev/null +++ b/android/app/src/main/assets/dist-library-files @@ -0,0 +1 @@ +../../../../../dist-library-files/ \ No newline at end of file diff --git a/android/app/src/main/assets/dist-renderer-webpack b/android/app/src/main/assets/dist-renderer-webpack new file mode 120000 index 00000000..dd46b530 --- /dev/null +++ b/android/app/src/main/assets/dist-renderer-webpack @@ -0,0 +1 @@ +../../../../../dist-renderer-webpack/ \ No newline at end of file diff --git a/android/app/src/main/assets/l10n b/android/app/src/main/assets/l10n new file mode 120000 index 00000000..ea249202 --- /dev/null +++ b/android/app/src/main/assets/l10n @@ -0,0 +1 @@ +../../../../../src-main/l10n/ \ No newline at end of file diff --git a/android/app/src/main/assets/preload/editor.js b/android/app/src/main/assets/preload/editor.js new file mode 100644 index 00000000..07d4c3de --- /dev/null +++ b/android/app/src/main/assets/preload/editor.js @@ -0,0 +1,87 @@ +const {contextBridge, ipcRenderer} = require('electron'); + +contextBridge.exposeInMainWorld('EditorPreload', { + isInitiallyFullscreen: () => ipcRenderer.sendSync('is-initially-fullscreen'), + getInitialFile: async () => null, + getFile: (id) => ipcRenderer.invoke('get-file', id), + openedFile: (id) => ipcRenderer.invoke('opened-file', id), + closedFile: () => ipcRenderer.invoke('closed-file'), + showSaveFilePicker: (suggestedName) => ipcRenderer.invoke('show-save-file-picker', suggestedName), + showOpenFilePicker: () => ipcRenderer.invoke('show-open-file-picker'), + setLocale: (locale) => ipcRenderer.sendSync('set-locale', locale), + setChanged: (changed) => {}, + // openNewWindow: () => ipcRenderer.invoke('open-new-window'), + openAddonSettings: (search) => ipcRenderer.sendSync('open-addon-settings', search), + //openPackager: () => ipcRenderer.invoke('open-packager'), + //openDesktopSettings: () => ipcRenderer.invoke('open-desktop-settings'), + openPrivacy: () => ipcRenderer.sendSync('open-privacy'), + openAbout: () => ipcRenderer.sendSync('open-about'), + //getPreferredMediaDevices: () => ipcRenderer.invoke('get-preferred-media-devices'), + getAdvancedCustomizations: async () => ({}), + setExportForPackager: (callback) => {}, + setIsFullScreen: (isFullScreen) => {} +}); + +//let exportForPackager = () => Promise.reject(new Error('exportForPackager missing')); + +//ipcRenderer.on('export-project-to-port', (e) => { +// const port = e.ports[0]; +// exportForPackager() +// .then(({data, name}) => { +// port.postMessage({ data, name }); +// }) +// .catch((error) => { +// console.error(error); +// port.postMessage({ error: true }); +// }); +//}); + +//window.addEventListener('message', (e) => { +// if (e.source === window) { +// const data = e.data; +// if (data && typeof data.ipcStartWriteStream === 'string') { +// ipcRenderer.postMessage('start-write-stream', data.ipcStartWriteStream, e.ports); +// } +// } +//}); + +//ipcRenderer.on('enumerate-media-devices', (e) => { +// navigator.mediaDevices.enumerateDevices() +// .then((devices) => { +// e.sender.send('enumerated-media-devices', { +// devices: devices.map((device) => ({ +// deviceId: device.deviceId, +// kind: device.kind, +// label: device.label +// })) +// }); +// }) +// .catch((error) => { +// console.error(error); +// e.sender.send('enumerated-media-devices', { +// error: `${error}` +// }); +// }); +//}); + +//contextBridge.exposeInMainWorld('PromptsPreload', { +// alert: (message) => ipcRenderer.sendSync('alert', message), +// confirm: (message) => ipcRenderer.sendSync('confirm', message), +//}); + +// In some Linux environments, people may try to drag & drop files that we don't have access to. +// Remove when https://github.com/electron/electron/issues/30650 is fixed. +//if (navigator.userAgent.includes('Linux')) { +// document.addEventListener('drop', (e) => { +// if (e.isTrusted) { +// for (const file of e.dataTransfer.files) { +// // Using webUtils is safe as we don't have a legacy build for Linux +// const {webUtils} = require('electron'); +// const path = webUtils.getPathForFile(file); +// ipcRenderer.invoke('check-drag-and-drop-path', path); +// } +// } +// }, { +// capture: true +// }); +//} diff --git a/android/app/src/main/assets/preload/ipc-init.js b/android/app/src/main/assets/preload/ipc-init.js new file mode 100644 index 00000000..e69d0929 --- /dev/null +++ b/android/app/src/main/assets/preload/ipc-init.js @@ -0,0 +1,63 @@ +const require = (function() { + let globalIpcCounter = 0; + const ipcInFlight = {}; + + AndroidIpcAsync.onmessage = (e) => { + const data = e.data; + const ipcObject = ipcInFlight[data.messageId]; + if (!ipcObject) { + throw new Error(`Received async IPC with unknown ID ${data.messageId}`); + } + + if (data.success) { + ipcObject.resolve(data.result); + } else { + ipcObject.reject(data.result); + } + + delete ipcObject[data.messageId]; + }; + + const contextBridge = { + exposeInMainWorld: (objectName, objectImplementation) => { + window[objectName] = objectImplementation; + } + }; + + const ipcRenderer = { + sendSync: (method, ...args) => { + const response = AndroidIpcSync.sendSync(JSON.stringify({ + method, + args + })); + return JSON.parse(response); + }, + + invoke: (method, ...args) => new Promise((resolve, reject) => { + const messageId = globalIpcCounter++; + ipcInFlight[messageId] = { + resolve, + reject + }; + + console.log('Sending', method, args); + + AndroidIpcAsync.postMessage({ + messageId, + method, + arguments: args + }); + }), + }; + + return (moduleName) => { + if (moduleName === "electron") { + return { + contextBridge, + ipcRenderer + }; + } + + throw new Error(`Mock require() found unknown module: ${moduleName}`); + }; +}()); diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..f64a7793 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/org/turbowarp/android/AboutView.kt b/android/app/src/main/java/org/turbowarp/android/AboutView.kt new file mode 100644 index 00000000..68eb5156 --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/AboutView.kt @@ -0,0 +1,40 @@ +package org.turbowarp.android + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp + +@Composable +fun AboutView() { + Surface( + modifier = Modifier.fillMaxSize(), + color = Color(0xffff4c4c) + ) { + Column ( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "TurboWarp for Android", + fontSize = 30.sp, + color = Color.White, + textAlign = TextAlign.Center + ) + Text( + text = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME}", + fontSize = 20.sp, + color = Color.White, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/android/app/src/main/java/org/turbowarp/android/AddonsView.kt b/android/app/src/main/java/org/turbowarp/android/AddonsView.kt new file mode 100644 index 00000000..7b0be645 --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/AddonsView.kt @@ -0,0 +1,10 @@ +package org.turbowarp.android + +import androidx.compose.runtime.Composable + +@Composable +fun AddonsView() { + TurboWarpWebView( + url = "https://editor.android-assets.turbowarp.org/addons/addons.html", + ) +} diff --git a/android/app/src/main/java/org/turbowarp/android/Assets.kt b/android/app/src/main/java/org/turbowarp/android/Assets.kt new file mode 100644 index 00000000..fcefdbd4 --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/Assets.kt @@ -0,0 +1,17 @@ +package org.turbowarp.android + +import android.content.Context +import org.brotli.dec.BrotliInputStream +import java.io.InputStream + +fun readAssetAsString(context: Context, path: String): String { + val stream = context.assets.open(path) + val reader = stream.bufferedReader() + return reader.readText() +} + +fun readBrotliAssetAsStream(context: Context, path: String): InputStream { + val compressedDataStream = context.assets.open(path) + val decompressedDataStream = BrotliInputStream(compressedDataStream) + return decompressedDataStream +} \ No newline at end of file diff --git a/android/app/src/main/java/org/turbowarp/android/EditorView.kt b/android/app/src/main/java/org/turbowarp/android/EditorView.kt new file mode 100644 index 00000000..ae8ca37b --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/EditorView.kt @@ -0,0 +1,89 @@ +package org.turbowarp.android + +import android.os.Handler +import android.os.Looper +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import org.json.JSONArray +import org.json.JSONObject + +fun mapToJsonObject(map: Map): JSONObject { + val jsonObject = JSONObject() + for ((key, value) in map) { + jsonObject.put(key, value) + } + return jsonObject +} + +@Composable +fun EditorView() { + val navController = rememberNavController() + + val editor = TurboWarpWebView( + url = "https://editor.android-assets.turbowarp.org/gui/gui.html", + preloads = listOf( + "editor.js", + ), + ipcHandler = object : IpcHandler { + override fun handleSync( + method: String, + args: JSONArray + ): Any? { + if (method == "is-initially-fullscreen") { + return false + } + + if (method == "set-locale") { + val result = JSONObject() + val strings = L10N.getStrings() + result.put("strings", mapToJsonObject(strings)) + return result + } + + if (method == "open-about") { + Handler(Looper.getMainLooper()).post { + navController.navigate("about") + } + return null + } + + if (method == "open-addon-settings") { + Handler(Looper.getMainLooper()).post { + navController.navigate("addons") + } + return null + } + + if (method == "open-privacy") { + Handler(Looper.getMainLooper()).post { + navController.navigate("privacy") + } + return null + } + + return null + } + } + ) + + Box { + editor + NavHost(navController = navController, startDestination = "none") { + composable("none") { + // empty + } + composable("about") { + AboutView() + } + composable("addons") { + AddonsView() + } + composable("privacy") { + PrivacyView() + } + } + } +} diff --git a/android/app/src/main/java/org/turbowarp/android/L10N.kt b/android/app/src/main/java/org/turbowarp/android/L10N.kt new file mode 100644 index 00000000..3adfe27e --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/L10N.kt @@ -0,0 +1,42 @@ +package org.turbowarp.android + +import android.content.Context +import org.json.JSONObject + +object L10N { + private val translations = mutableMapOf>() + + private fun stringsWithDescriptionToMap(jsonObject: JSONObject): Map { + val map = mutableMapOf() + for (id in jsonObject.keys()) { + val infoObject = jsonObject.getJSONObject(id) + map[id] = infoObject.getString("string") + } + return map + } + + private fun stringsToMap(jsonObject: JSONObject): Map { + val map = mutableMapOf() + for (id in jsonObject.keys()) { + map[id] = jsonObject.getString(id) + } + return map + } + + fun setup(context: Context) { + val englishStrings = readAssetAsString(context, "l10n/en.json") + val englishStringsObject = JSONObject(englishStrings) + translations["en"] = stringsWithDescriptionToMap(englishStringsObject) + + val translatedStrings = readAssetAsString(context, "l10n/generated-translations.json") + val translatedStringsObjects = JSONObject(translatedStrings) + for (locale in translatedStringsObjects.keys()) { + val localeStringsObject = translatedStringsObjects.getJSONObject(locale) + translations[locale] = stringsToMap(localeStringsObject) + } + } + + fun getStrings(): Map { + return translations["en"]!! + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/turbowarp/android/MainActivity.kt b/android/app/src/main/java/org/turbowarp/android/MainActivity.kt new file mode 100644 index 00000000..9192952b --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/MainActivity.kt @@ -0,0 +1,32 @@ +package org.turbowarp.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.ui.Modifier +import androidx.navigation.compose.rememberNavController +import androidx.webkit.WebViewFeature +import org.turbowarp.android.ui.theme.TurboWarpTheme + +// TODO +private fun isDeviceSupported(): Boolean { + return WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) +} + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + L10N.setup(applicationContext) + setContent { + TurboWarpTheme { + Box(modifier = Modifier.safeDrawingPadding()) { + EditorView() + } + } + } + } +} diff --git a/android/app/src/main/java/org/turbowarp/android/PrivacyView.kt b/android/app/src/main/java/org/turbowarp/android/PrivacyView.kt new file mode 100644 index 00000000..c5965f34 --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/PrivacyView.kt @@ -0,0 +1,18 @@ +package org.turbowarp.android + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun PrivacyView() { + Surface( + modifier = Modifier.fillMaxSize(), + color = Color.White + ) { + Text("Testing") + } +} \ No newline at end of file diff --git a/android/app/src/main/java/org/turbowarp/android/TurboWarpWebView.kt b/android/app/src/main/java/org/turbowarp/android/TurboWarpWebView.kt new file mode 100644 index 00000000..a9957885 --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/TurboWarpWebView.kt @@ -0,0 +1,287 @@ +package org.turbowarp.android + +import android.annotation.SuppressLint +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import android.view.ViewGroup +import android.webkit.JavascriptInterface +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewAssetLoader +import androidx.webkit.WebViewCompat +import java.io.IOException +import java.net.URLConnection +import androidx.core.net.toUri +import org.json.JSONArray +import org.json.JSONObject +import java.io.InputStream + +private fun addIndexIfNeeded(path: String): String { + return if (path.endsWith("/")) { + "$path/index.html" + } else { + path + } +} + +private fun makeFetchableResponse(data: InputStream, path: String): WebResourceResponse { + // TODO: use our own mime types instead of the system's + val mimeType = URLConnection.guessContentTypeFromName(path) + + return WebResourceResponse( + mimeType, + null, + 200, + "OK", + mapOf( + "Access-Control-Allow-Origin" to "*" + ), + data, + ) +} + +private fun makeErrorResponse(): WebResourceResponse { + // TODO + return WebResourceResponse(null, null, null) +} + +private class ServeAsset( + private val context: Context, + private val subfolder: String +) : WebViewAssetLoader.PathHandler { + override fun handle(path: String): WebResourceResponse? { + return try { + // TODO: probably vulnerable to path traversal + val pathWithIndex = addIndexIfNeeded(path) + val assetPath = "$subfolder/$pathWithIndex" + + val stream = context.assets.open(assetPath) + makeFetchableResponse(stream, pathWithIndex) + } catch (_: IOException) { + makeErrorResponse() + } + } +} + +private class ServeBrotliAsset( + private val context: Context, + private val subfolder: String +) : WebViewAssetLoader.PathHandler { + override fun handle(path: String): WebResourceResponse? { + return try { + // TODO: probably vulnerable to path traversal + val pathWithIndex = addIndexIfNeeded(path) + val compressedAssetPath = "$subfolder/$pathWithIndex.br" + + val stream = readBrotliAssetAsStream(context, compressedAssetPath) + makeFetchableResponse(stream, pathWithIndex) + } catch (_: IOException) { + // TODO: does this fall-through to remote or error? + null + } + } +} + +private class ServeLibraryAsset( + private val context: Context, + private val subfolder: String +) : WebViewAssetLoader.PathHandler { + private fun findMd5ext(path: String): String? { + val md5ext = Regex("[0-9a-f]{32}\\.\\w{3}", RegexOption.IGNORE_CASE).find(path) + return md5ext?.value + } + + override fun handle(path: String): WebResourceResponse? { + return try { + val md5ext = findMd5ext(path) + + if (md5ext == null) { + makeErrorResponse() + } else { + val compressedAssetPath = "$subfolder/$md5ext.br" + val stream = readBrotliAssetAsStream(context, compressedAssetPath) + makeFetchableResponse(stream, md5ext) + } + } catch (_: IOException) { + makeErrorResponse() + } + } +} + +private class TurboWarpWebViewClient( + private val context: Context, + private val preloads: List, + private val initialUrl: String +) : WebViewClient() { + private val assetLoaders = mapOf( + "editor.android-assets.turbowarp.org" to WebViewAssetLoader.Builder() + .setDomain("editor.android-assets.turbowarp.org") + .addPathHandler("/", ServeAsset(context, "dist-renderer-webpack/editor")) + .build(), + + "extensions.turbowarp.org" to WebViewAssetLoader.Builder() + .setDomain("extensions.turbowarp.org") + .addPathHandler("/", ServeBrotliAsset(context, "dist-extensions")) + .build(), + + "assets.scratch.mit.edu" to WebViewAssetLoader.Builder() + .setDomain("assets.scratch.mit.edu") + .addPathHandler("/", ServeLibraryAsset(context, "dist-library-files")) + .build(), + + "cdn.assets.scratch.mit.edu" to WebViewAssetLoader.Builder() + .setDomain("cdn.assets.scratch.mit.edu") + .addPathHandler("/", ServeLibraryAsset(context, "dist-library-files")) + .build(), + + "packager.turbowarp.org" to WebViewAssetLoader.Builder() + .setDomain("packager.turbowarp.org") + .addPathHandler("/", ServeAsset(context, "packager")) + .build(), + ) + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + + // Execute preloads in IIFE so that we only expose the variables we want to + val sb = StringBuilder() + sb.append("(function() { 'use strict';\n") + for (preloadName in preloads) { + // We assume that the preloads variable is trusted, don't need to worry about path + // traversal or anything like that. + val preloadScript = readAssetAsString(context, "preload/$preloadName") + sb.append(preloadScript) + } + sb.append("\n}());") + + view?.evaluateJavascript(sb.toString(), null) + } + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + val loader = request.url.host?.let { assetLoaders[it] } + return loader?.shouldInterceptRequest(request.url) + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + // Open links in browser app + // TODO: can we make this feel a bit less weird? like that custom tabs thing? + if (request?.url.toString() != initialUrl) { + val intent = Intent(Intent.ACTION_VIEW, request?.url) + view?.context?.startActivity(intent) + return true + } + + return super.shouldOverrideUrlLoading(view, request) + } +} + +interface IpcHandler { + fun handleSync(method: String, args: JSONArray): Any? +} + +private class IpcSync(private val ipcHandler: IpcHandler) { + // Android's JavaScript interface apparently only supports the primitive types. + // So we have to pass around JSON strings. Real fun. + @JavascriptInterface + fun sendSync(jsonRequestString: String): String { + val jsonRequest = JSONObject(jsonRequestString) + val method = jsonRequest.getString("method") + val args = jsonRequest.getJSONArray("args") + + val jsonResponse = ipcHandler.handleSync(method, args) + return jsonResponse?.toString() ?: "null" + } +} + +private class IpcAsync(private val ipcHandler: IpcHandler) : WebViewCompat.WebMessageListener { + @SuppressLint("RequiresFeature") + override fun onPostMessage( + view: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy + ) { + replyProxy.postMessage("e") + } +} + +private fun getOrigin(url: String): String { + val uri = url.toUri() + val sb = StringBuilder() + sb.append(uri.scheme) + sb.append("://") + sb.append(uri.host) + return sb.toString() +} + +@SuppressLint("SetJavaScriptEnabled", "RequiresFeature") +@Composable +fun TurboWarpWebView( + url: String, + modifier: Modifier = Modifier, + preloads: List = emptyList(), + ipcHandler: IpcHandler? = null +) { + AndroidView( + modifier = modifier, + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + + // Enable standard web APIs + settings.javaScriptEnabled = true + settings.domStorageEnabled = true + + // All of our assets come through custom asset loaders, so + // don't disable file access, like browsers do. + settings.allowFileAccess = false + settings.allowContentAccess = false + + // To help troubleshooting + val version = BuildConfig.VERSION_NAME + val appId = BuildConfig.APPLICATION_ID + settings.userAgentString += " $appId/$version" + + if (ipcHandler != null) { + val origin = getOrigin(url) + WebViewCompat.addWebMessageListener( + this, + "AndroidIpcAsync", + setOf(origin), + IpcAsync(ipcHandler) + ) + addJavascriptInterface(IpcSync(ipcHandler), "AndroidIpcSync") + } + + webViewClient = TurboWarpWebViewClient( + context = context, + initialUrl = url, + preloads = if (ipcHandler == null) { + preloads + } else { + listOf("ipc-init.js").plus(preloads) + } + ) + } + }, + update = { webView -> + webView.loadUrl(url) + } + ) +} diff --git a/android/app/src/main/java/org/turbowarp/android/ui/theme/Color.kt b/android/app/src/main/java/org/turbowarp/android/ui/theme/Color.kt new file mode 100644 index 00000000..a7e222bc --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package org.turbowarp.android.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/android/app/src/main/java/org/turbowarp/android/ui/theme/Theme.kt b/android/app/src/main/java/org/turbowarp/android/ui/theme/Theme.kt new file mode 100644 index 00000000..d44a5974 --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package org.turbowarp.android.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun TurboWarpTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android/app/src/main/java/org/turbowarp/android/ui/theme/Type.kt b/android/app/src/main/java/org/turbowarp/android/ui/theme/Type.kt new file mode 100644 index 00000000..9a73b7b4 --- /dev/null +++ b/android/app/src/main/java/org/turbowarp/android/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package org.turbowarp.android.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..09d5bce4 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..7353dbd1 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..7353dbd1 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..8aaa9ea3 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..8aaa9ea3 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..93e61032 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..93e61032 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..d1dddc72 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..d1dddc72 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..cdd69ee9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..cdd69ee9 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..97de11c5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..97de11c5 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..6b30d8d9 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/attrs_my_view.xml b/android/app/src/main/res/values/attrs_my_view.xml new file mode 100644 index 00000000..e5f8fdc2 --- /dev/null +++ b/android/app/src/main/res/values/attrs_my_view.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..66e3f6cf --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,14 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #FF29B6F6 + #FF039BE5 + #FFBDBDBD + #FF757575 + \ No newline at end of file diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..60904177 --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FF4C4C + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..ad3799d1 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + TurboWarp + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..6b30d8d9 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..3bffc1cc --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +