diff --git a/frontend/src/components/Notifications/index.tsx b/frontend/src/components/Notifications/index.tsx index 140cb821..711658c0 100644 --- a/frontend/src/components/Notifications/index.tsx +++ b/frontend/src/components/Notifications/index.tsx @@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom'; import Close from '@mui/icons-material/Close'; import { type Page } from '../../basic/NavBar'; import { GarageContext, type UseGarageStoreType } from '../../contexts/GarageContext'; +import { getSettings } from '../../contexts/AppContext'; interface NotificationsProps { rewards: number | undefined; @@ -30,9 +31,9 @@ interface NotificationMessage { } const path = - window.NativeRobosats === undefined - ? '/static/assets/sounds' - : 'file:///android_asset/Web.bundle/assets/sounds'; + getSettings().client == 'mobile' + ? 'file:///android_asset/Web.bundle/assets/sounds' + : '/static/assets/sounds'; const audio = { chat: new Audio(`${path}/chat-open.mp3`), diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx index 29c15683..15c0bda6 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx @@ -20,11 +20,12 @@ import { type UseFederationStoreType, FederationContext, } from '../../../../contexts/FederationContext'; +import { getSettings } from '../../../../contexts/AppContext'; const audioPath = - window.NativeRobosats === undefined - ? '/static/assets/sounds' - : 'file:///android_asset/Web.bundle/assets/sounds'; + getSettings().client == 'mobile' + ? 'file:///android_asset/Web.bundle/assets/sounds' + : '/static/assets/sounds'; interface Props { order: Order; diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx index 1121f47b..10f2cbf3 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx @@ -19,6 +19,7 @@ import { } from '../../../../contexts/FederationContext'; import { type UseGarageStoreType, GarageContext } from '../../../../contexts/GarageContext'; import { type Order } from '../../../../models'; +import { getSettings } from '../../../../contexts/AppContext'; interface Props { order: Order; @@ -35,9 +36,9 @@ interface Props { } const audioPath = - window.NativeRobosats === undefined - ? '/static/assets/sounds' - : 'file:///android_asset/Web.bundle/assets/sounds'; + getSettings().client == 'mobile' + ? 'file:///android_asset/Web.bundle/assets/sounds' + : '/static/assets/sounds'; const EncryptedTurtleChat: React.FC = ({ order, diff --git a/frontend/src/contexts/AppContext.tsx b/frontend/src/contexts/AppContext.tsx index a7b5690d..f051a2a7 100644 --- a/frontend/src/contexts/AppContext.tsx +++ b/frontend/src/contexts/AppContext.tsx @@ -36,13 +36,8 @@ export interface SlideDirection { export type TorStatus = 'ON' | 'STARTING' | 'STOPPING' | 'OFF'; -export const isNativeRoboSats = !(window.NativeRobosats === undefined); - const pageFromPath = window.location.pathname.split('/')[1]; const isPagePathEmpty = pageFromPath === ''; -const entryPage: Page = !isNativeRoboSats - ? ((isPagePathEmpty ? 'garage' : pageFromPath) as Page) - : 'garage'; export const closeAll: OpenDialogs = { more: false, @@ -56,6 +51,7 @@ export const closeAll: OpenDialogs = { update: false, profile: false, recovery: false, + thirdParty: '', }; const makeTheme = function (settings: Settings): Theme { @@ -108,7 +104,7 @@ const getOrigin = (network = 'mainnet'): Origin => { return origin; }; -const getSettings = (): Settings => { +export const getSettings = (): Settings => { let settings; const [client, view] = window.RobosatsSettings.split('-'); @@ -120,6 +116,11 @@ const getSettings = (): Settings => { return settings; }; +const entryPage: Page = + getSettings().client == 'mobile' + ? 'garage' + : ((isPagePathEmpty ? 'garage' : pageFromPath) as Page); + export interface WindowSize { width: number; height: number; @@ -159,7 +160,7 @@ export interface UseAppStoreType { export const initialAppContext: UseAppStoreType = { theme: undefined, - torStatus: 'STARTING', + torStatus: 'ON', settings: getSettings(), setSettings: () => {}, page: entryPage, diff --git a/frontend/src/models/Settings.model.ts b/frontend/src/models/Settings.model.ts index 90a9b764..0653a35c 100644 --- a/frontend/src/models/Settings.model.ts +++ b/frontend/src/models/Settings.model.ts @@ -51,6 +51,7 @@ class BaseSettings { this.host = getHost(); const [client] = window.RobosatsSettings.split('-'); + this.client = client; const stopNotifications = systemClient.getItem('settings_stop_notifications'); this.stopNotifications = client === 'mobile' && stopNotifications === 'true'; @@ -63,6 +64,7 @@ class BaseSettings { public frontend: 'basic' | 'pro' = 'basic'; public mode: 'light' | 'dark' = 'light'; + public client: 'web' | 'mobile' = 'web'; public fontSize: number = 14; public lightQRs: boolean = false; public language?: Language; diff --git a/frontend/webpack.config.ts b/frontend/webpack.config.ts index 38330bd9..cb1d02d9 100644 --- a/frontend/webpack.config.ts +++ b/frontend/webpack.config.ts @@ -236,6 +236,60 @@ const configMobile: Configuration = { }, }, }), + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, 'templates/frontend/index.ejs'), + templateParameters: { + pro: false, + }, + filename: path.resolve(__dirname, '../mobile_new/app/src/main/assets/index.html'), + inject: 'body', + robosatsSettings: 'mobile-basic', + basePath: 'file:///android_asset/Web.bundle/', + }), + new FileManagerPlugin({ + events: { + onEnd: { + copy: [ + { + source: path.resolve(__dirname, 'static/css'), + destination: path.resolve( + __dirname, + '../mobile_new/app/src/main/assets/Web.bundle/static/css', + ), + }, + { + source: path.resolve(__dirname, 'static/assets/sounds'), + destination: path.resolve( + __dirname, + '../mobile_new/app/src/main/assets/Web.bundle/assets/sounds', + ), + }, + { + source: path.resolve(__dirname, 'static/federation'), + destination: path.resolve( + __dirname, + '../mobile_new/app/src/main/assets/Web.bundle/assets/federation', + ), + }, + ], + }, + }, + }), + new FileManagerPlugin({ + events: { + onEnd: { + copy: [ + { + source: path.resolve(__dirname, '../mobile/html/Web.bundle/static/frontend'), + destination: path.resolve( + __dirname, + '../mobile_new/app/src/main/assets/static/frontend', + ), + }, + ], + }, + }, + }), ], }; diff --git a/mobile_new/.gitignore b/mobile_new/.gitignore new file mode 100644 index 00000000..aa724b77 --- /dev/null +++ b/mobile_new/.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/mobile_new/app/.gitignore b/mobile_new/app/.gitignore new file mode 100644 index 00000000..9de4b82c --- /dev/null +++ b/mobile_new/app/.gitignore @@ -0,0 +1,2 @@ +/build +/src/main/assets/* \ No newline at end of file diff --git a/mobile_new/app/build.gradle.kts b/mobile_new/app/build.gradle.kts new file mode 100644 index 00000000..51013fc6 --- /dev/null +++ b/mobile_new/app/build.gradle.kts @@ -0,0 +1,81 @@ +import com.android.build.api.dsl.Packaging + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.koalasat.robosats" + compileSdk = 36 + + defaultConfig { + applicationId = "com.koalasat.robosats" + 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" + } + + splits { + + // Configures multiple APKs based on ABI. This helps keep the size + // down, since PT binaries can be large. + abi { + + // Enables building multiple APKs per ABI. + isEnable = true + + // By default, all ABIs are included, so use reset() and include to specify + // that we only want APKs for x86 and x86_64, armeabi-v7a, and arm64-v8a. + + // Resets the list of ABIs that Gradle should create APKs for to none. + reset() + + // Specifies a list of ABIs that Gradle should create APKs for. + include("x86", "armeabi-v7a", "arm64-v8a", "x86_64") + + // Specify whether you wish to also generate a universal APK that + // includes _all_ ABIs. + isUniversalApk = true + } + } + + fun Packaging.() { + jniLibs.useLegacyPackaging = true + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.kmp.tor) + // Add the KMP Tor binary dependency (contains the native .so files) + implementation(libs.kmp.tor.binary) + implementation(libs.androidx.activity) + implementation(libs.androidx.constraintlayout) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} diff --git a/mobile_new/app/proguard-rules.pro b/mobile_new/app/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/mobile_new/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/mobile_new/app/src/androidTest/java/com/koalasat/robosats/ExampleInstrumentedTest.kt b/mobile_new/app/src/androidTest/java/com/koalasat/robosats/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..bc490e19 --- /dev/null +++ b/mobile_new/app/src/androidTest/java/com/koalasat/robosats/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.koalasat.robosats + +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("com.koalasat.robosats", appContext.packageName) + } +} diff --git a/mobile_new/app/src/main/AndroidManifest.xml b/mobile_new/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..e887e5fb --- /dev/null +++ b/mobile_new/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/mobile_new/app/src/main/java/com/koalasat/robosats/MainActivity.kt b/mobile_new/app/src/main/java/com/koalasat/robosats/MainActivity.kt new file mode 100644 index 00000000..9a5f71e1 --- /dev/null +++ b/mobile_new/app/src/main/java/com/koalasat/robosats/MainActivity.kt @@ -0,0 +1,508 @@ +package com.koalasat.robosats + +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.webkit.* +import androidx.appcompat.app.AppCompatActivity +import com.robosats.tor.TorKmp +import com.robosats.tor.TorKmpManager +import java.net.InetSocketAddress + +class MainActivity : AppCompatActivity() { + private lateinit var webView: WebView + private lateinit var torKmp: TorKmp + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // We don't need edge-to-edge since we're using fitsSystemWindows + setContentView(R.layout.activity_main) + + // Set up the WebView reference + webView = findViewById(R.id.webView) + + // Initialize Tor and setup WebView only after Tor is properly connected + initializeTor() + } + + private fun initializeTor() { + // Initialize TorKmp if it's not already initialized + try { + try { + torKmp = TorKmpManager.getTorKmpObject() + } catch (e: UninitializedPropertyAccessException) { + torKmp = TorKmp(application as Application) + TorKmpManager.updateTorKmpObject(torKmp) + torKmp.torOperationManager.startQuietly() + } + + // Run Tor connection check on a background thread + Thread { + waitForTorConnection() + }.start() + + } catch (e: Exception) { + // Log the error and show a critical error message + Log.e("TorInitialization", "Failed to initialize Tor: ${e.message}", e) + + // Show a toast notification about the critical error + runOnUiThread { + android.widget.Toast.makeText( + this, + "Critical error: Tor initialization failed. App cannot proceed securely.", + android.widget.Toast.LENGTH_LONG + ).show() + } + } + } + + private fun waitForTorConnection() { + var retries = 0 + val maxRetries = 15 + + try { + // Display connecting message + runOnUiThread { + android.widget.Toast.makeText( + this, + "Connecting to Tor network...", + android.widget.Toast.LENGTH_SHORT + ).show() + } + + // Wait for Tor to connect with retry mechanism + while (!torKmp.isConnected() && retries < maxRetries) { + if (!torKmp.isStarting()) { + torKmp.torOperationManager.startQuietly() + } + Thread.sleep(2000) + retries += 1 + + // Update status on UI thread every few retries + if (retries % 3 == 0) { + runOnUiThread { + android.widget.Toast.makeText( + this, + "Still connecting to Tor (attempt $retries/$maxRetries)...", + android.widget.Toast.LENGTH_SHORT + ).show() + } + } + } + + // Check if Tor connected successfully + if (torKmp.isConnected()) { + Log.d("TorInitialization", "Tor connected successfully after $retries retries") + + // Show success message + runOnUiThread { + android.widget.Toast.makeText( + this, + "Tor connected successfully", + android.widget.Toast.LENGTH_SHORT + ).show() + + // Now that Tor is connected, set up the WebView + setupWebView() + } + } else { + // If we've exhausted retries and still not connected + Log.e("TorInitialization", "Failed to connect to Tor after $maxRetries retries") + + runOnUiThread { + android.widget.Toast.makeText( + this, + "Failed to connect to Tor after multiple attempts. App cannot proceed securely.", + android.widget.Toast.LENGTH_LONG + ).show() + } + } + } catch (e: Exception) { + Log.e("TorInitialization", "Error during Tor connection: ${e.message}", e) + + runOnUiThread { + android.widget.Toast.makeText( + this, + "Error connecting to Tor: ${e.message}", + android.widget.Toast.LENGTH_LONG + ).show() + } + } + } + + private fun setupWebView() { + // Double-check Tor is connected before proceeding + if (!torKmp.isConnected()) { + Log.e("SecurityError", "Attempted to set up WebView without Tor connection") + return + } + + // IMMEDIATELY set a blocking WebViewClient to prevent ANY network access + webView.webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { + // Block ALL requests until we're sure Tor proxy is correctly set up + return WebResourceResponse("text/plain", "UTF-8", null) + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + // Block ALL URL loading attempts until proxy is properly configured + return true + } + } + + // Configure WebView settings on UI thread + val webSettings = webView.settings + + // Enable JavaScript + webSettings.javaScriptEnabled = true + + // Enable DOM storage for HTML5 apps + webSettings.domStorageEnabled = true + + // Enable CORS and cross-origin requests + webSettings.allowUniversalAccessFromFileURLs = true + webSettings.allowFileAccessFromFileURLs = true + + // Disable cache completely to prevent leaks + webSettings.cacheMode = WebSettings.LOAD_NO_CACHE + + // Enable mixed content (http in https) + webSettings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW + + // Enable zooming + webSettings.setSupportZoom(true) + webSettings.builtInZoomControls = true + webSettings.displayZoomControls = false + + // Enable HTML5 features + webSettings.allowFileAccess = true + webSettings.allowContentAccess = true + webSettings.loadWithOverviewMode = true + webSettings.useWideViewPort = true + webSettings.setSupportMultipleWindows(true) + webSettings.javaScriptCanOpenWindowsAutomatically = true + + // Improve display for better Android integration + webSettings.textZoom = 100 // Normal text zoom + + // Show message that we're setting up secure browsing + runOnUiThread { + android.widget.Toast.makeText( + this, + "Setting up secure Tor browsing...", + android.widget.Toast.LENGTH_SHORT + ).show() + } + + // Configure proxy for WebView in a background thread to avoid NetworkOnMainThreadException + Thread { + try { + // First verify Tor is still connected + if (!torKmp.isConnected()) { + throw SecurityException("Tor disconnected during proxy setup") + } + + // Try to set up the proxy + setupProxyForWebView() + + // If we get here, proxy setup was successful + // Perform one final Tor connection check + if (!torKmp.isConnected()) { + throw SecurityException("Tor disconnected after proxy setup") + } + + // Now get the proxy information that we previously verified in setupProxyForWebView + // Use system properties that we've already set up and verified + val proxyHost = System.getProperty("http.proxyHost") + ?: throw SecurityException("Missing proxy host in system properties") + val proxyPort = System.getProperty("http.proxyPort")?.toIntOrNull() + ?: throw SecurityException("Missing or invalid proxy port in system properties") + + Log.d("TorProxy", "Using proxy settings: $proxyHost:$proxyPort") + + // Success - now configure WebViewClient and load URL on UI thread + runOnUiThread { + android.widget.Toast.makeText( + this, + "Secure connection established", + android.widget.Toast.LENGTH_SHORT + ).show() + + // Create a custom WebViewClient that forces all traffic through Tor + webView.webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { + // Verify Tor is connected before allowing any resource request + if (!torKmp.isConnected()) { + Log.e("SecurityError", "Tor disconnected during resource request") + return WebResourceResponse("text/plain", "UTF-8", null) + } + + val urlString = request.url.toString() + Log.d("TorProxy", "Intercepting request: $urlString") + + try { + // Special handling for .onion domains + val isOnionDomain = urlString.contains(".onion") + + // For .onion domains, we must use SOCKS proxy type + val proxyType = if (isOnionDomain) + java.net.Proxy.Type.SOCKS + else + java.net.Proxy.Type.HTTP + + // Create a proxy instance for Tor with the appropriate type + val torProxy = java.net.Proxy( + proxyType, + java.net.InetSocketAddress(proxyHost, proxyPort) + ) + + if (isOnionDomain) { + Log.d("TorProxy", "Handling .onion domain with SOCKS proxy: $urlString") + } + + // Create connection with proxy already configured + val url = java.net.URL(urlString) + val connection = url.openConnection(torProxy) + + // Configure basic connection properties + connection.connectTimeout = 60000 // Longer timeout for onion domains + connection.readTimeout = 60000 + + if (connection is java.net.HttpURLConnection) { + // Ensure no connection reuse to prevent proxy leaks + connection.setRequestProperty("Connection", "close") + + // Copy request headers + request.requestHeaders.forEach { (key, value) -> + connection.setRequestProperty(key, value) + } + + // Special handling for OPTIONS (CORS preflight) requests + if (request.method == "OPTIONS") { + // Handle preflight CORS request + connection.requestMethod = "OPTIONS" + connection.setRequestProperty("Access-Control-Request-Method", + request.requestHeaders["Access-Control-Request-Method"] ?: "GET, POST, OPTIONS") + connection.setRequestProperty("Access-Control-Request-Headers", + request.requestHeaders["Access-Control-Request-Headers"] ?: "") + } else { + // Set request method for non-OPTIONS requests + connection.requestMethod = request.method + } + + // Try to connect + connection.connect() + val responseCode = connection.responseCode + + // Get content type + val mimeType = connection.contentType ?: "text/plain" + val encoding = connection.contentEncoding ?: "UTF-8" + + Log.d("TorProxy", "Successfully proxied request to $url (HTTP ${connection.responseCode})") + + // Get the correct input stream based on response code + val inputStream = if (responseCode >= 400) { + connection.errorStream ?: java.io.ByteArrayInputStream(byteArrayOf()) + } else { + connection.inputStream + } + + // Create response headers map with CORS headers + val responseHeaders = HashMap() + + // Add CORS headers + responseHeaders["Access-Control-Allow-Origin"] = "*" + responseHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + responseHeaders["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept" + responseHeaders["Access-Control-Allow-Credentials"] = "true" + + // Copy original response headers + for (i in 0 until connection.headerFields.size) { + val key = connection.headerFields.keys.elementAtOrNull(i) + if (key != null) { + val value = connection.getHeaderField(key) + if (value != null) { + responseHeaders[key] = value + } + } + } + + // Return proxied response with CORS headers + return WebResourceResponse( + mimeType, + encoding, + responseCode, + "OK", + responseHeaders, + inputStream + ) + } else { + // For non-HTTP connections (rare) + val inputStream = connection.getInputStream() + Log.d("TorProxy", "Successfully established non-HTTP connection to $url") + return WebResourceResponse( + "application/octet-stream", + "UTF-8", + inputStream + ) + } + } catch (e: Exception) { + Log.e("TorProxy", "Error proxying request: $urlString - ${e.message}", e) + + // For non-onion domains, let the system handle it + return super.shouldInterceptRequest(view, request) + } + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + // Verify Tor is still connected before allowing any request + if (!torKmp.isConnected()) { + Log.e("SecurityError", "Tor disconnected during navigation") + return true // Block the request + } + return false // Let our proxied client handle it + } + + override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) { + Log.e("WebViewError", "Error loading resource: ${error.description}") + super.onReceivedError(view, request, error) + } + + override fun onPageStarted(view: WebView, url: String, favicon: android.graphics.Bitmap?) { + // Verify Tor is connected when page starts loading + if (!torKmp.isConnected()) { + Log.e("SecurityError", "Tor disconnected as page started loading") + view.stopLoading() + return + } + super.onPageStarted(view, url, favicon) + } + + override fun onPageFinished(view: WebView, url: String) { + // Verify Tor is still connected when page finishes loading + if (!torKmp.isConnected()) { + Log.e("SecurityError", "Tor disconnected after page loaded") + return + } + + // No JavaScript injection - just log page load completion + Log.d("WebView", "Page finished loading: $url") + + super.onPageFinished(view, url) + } + } + + // Now it's safe to load the local HTML file + webView.loadUrl("file:///android_asset/index.html") + } + } catch (e: Exception) { + Log.e("WebViewSetup", "Security error in WebView setup: ${e.message}", e) + + // Show error and exit - DO NOT LOAD WEBVIEW + runOnUiThread { + // Show toast with error + android.widget.Toast.makeText( + this, + "SECURITY ERROR: Cannot set up secure browsing: ${e.message}", + android.widget.Toast.LENGTH_LONG + ).show() + } + } + }.start() + } + + private fun setupProxyForWebView() { + // Triple-check Tor is connected + if (!torKmp.isConnected()) { + throw SecurityException("Cannot set up proxy - Tor is not connected") + } + + try { + // Get the proxy from TorKmpManager, handling possible exceptions + val proxy = TorKmpManager.getTorKmpObject().proxy ?: + throw SecurityException("Tor proxy is null despite Tor being connected") + + val inetSocketAddress = proxy.address() as InetSocketAddress + val host = inetSocketAddress.hostName + val port = inetSocketAddress.port + + if (host.isBlank() || port <= 0) { + throw SecurityException("Invalid Tor proxy address: $host:$port") + } + + Log.d("WebViewProxy", "Setting up Tor proxy: $host:$port") + + // Set up the proxy + setWebViewProxy(applicationContext, host, port) + + // Verify proxy was set correctly + if (System.getProperty("http.proxyHost") != host || + System.getProperty("http.proxyPort") != port.toString()) { + throw SecurityException("Proxy verification failed - system properties don't match expected values") + } + + Log.d("WebViewProxy", "Proxy setup completed successfully") + } catch (e: Exception) { + Log.e("WebViewProxy", "Error setting up proxy: ${e.message}", e) + throw SecurityException("Failed to set up Tor proxy: ${e.message}", e) + } + } + + /** + * Sets the proxy for WebView using the most direct approach that's known to work with Tor + */ + private fun setWebViewProxy(context: Context, proxyHost: String, proxyPort: Int) { + try { + // First set system properties (required as a foundation) + System.setProperty("http.proxyHost", proxyHost) + System.setProperty("http.proxyPort", proxyPort.toString()) + System.setProperty("https.proxyHost", proxyHost) + System.setProperty("https.proxyPort", proxyPort.toString()) + System.setProperty("proxy.host", proxyHost) + System.setProperty("proxy.port", proxyPort.toString()) + + Log.d("WebViewProxy", "Set system proxy properties") + + // Create and apply a proxy at the application level + val proxyClass = Class.forName("android.net.ProxyInfo") + val proxyConstructor = proxyClass.getConstructor(String::class.java, Int::class.javaPrimitiveType, String::class.java) + val proxyInfo = proxyConstructor.newInstance(proxyHost, proxyPort, null) + + try { + // Try to set global proxy through ConnectivityManager + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) + val setDefaultProxyMethod = connectivityManager.javaClass.getDeclaredMethod("setDefaultProxy", proxyClass) + setDefaultProxyMethod.isAccessible = true + setDefaultProxyMethod.invoke(connectivityManager, proxyInfo) + Log.d("WebViewProxy", "Set proxy via ConnectivityManager") + } catch (e: Exception) { + Log.w("WebViewProxy", "Could not set proxy via ConnectivityManager: ${e.message}") + } + + // WebView operations must be run on the UI thread + runOnUiThread { + try { + // Force WebView to use proxy via direct settings (must be on UI thread) + webView.settings.javaClass.getDeclaredMethod("setHttpProxy", String::class.java, Int::class.javaPrimitiveType) + ?.apply { isAccessible = true } + ?.invoke(webView.settings, proxyHost, proxyPort) + Log.d("WebViewProxy", "Applied proxy directly to WebView settings") + } catch (e: Exception) { + Log.w("WebViewProxy", "Could not set proxy directly on WebView settings: ${e.message}") + // Continue - we'll rely on system properties and connection-level proxying + } + } + + // Wait to ensure UI thread operations complete + // This prevents race conditions with WebView operations + Thread.sleep(500) + + Log.d("WebViewProxy", "Proxy setup completed") + } catch (e: Exception) { + Log.e("WebViewProxy", "Error setting WebView proxy", e) + throw SecurityException("Failed to set WebView proxy: ${e.message}", e) + } + } +} diff --git a/mobile_new/app/src/main/java/com/koalasat/robosats/tor/EnumTorState.kt b/mobile_new/app/src/main/java/com/koalasat/robosats/tor/EnumTorState.kt new file mode 100644 index 00000000..8ddc7da5 --- /dev/null +++ b/mobile_new/app/src/main/java/com/koalasat/robosats/tor/EnumTorState.kt @@ -0,0 +1,8 @@ +package com.robosats.tor + +enum class EnumTorState { + STARTING, + ON, + STOPPING, + OFF +} diff --git a/mobile_new/app/src/main/java/com/koalasat/robosats/tor/TorKmpManager.kt b/mobile_new/app/src/main/java/com/koalasat/robosats/tor/TorKmpManager.kt new file mode 100644 index 00000000..9accff0c --- /dev/null +++ b/mobile_new/app/src/main/java/com/koalasat/robosats/tor/TorKmpManager.kt @@ -0,0 +1,403 @@ +package com.robosats.tor + +import android.app.Application +import android.util.Log +import android.widget.Toast +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.matthewnelson.kmp.tor.KmpTorLoaderAndroid +import io.matthewnelson.kmp.tor.TorConfigProviderAndroid +import io.matthewnelson.kmp.tor.common.address.* +import io.matthewnelson.kmp.tor.controller.common.config.TorConfig +import io.matthewnelson.kmp.tor.controller.common.config.TorConfig.Option.* +import io.matthewnelson.kmp.tor.controller.common.config.TorConfig.Setting.* +import io.matthewnelson.kmp.tor.controller.common.control.usecase.TorControlInfoGet +import io.matthewnelson.kmp.tor.controller.common.control.usecase.TorControlSignal +import io.matthewnelson.kmp.tor.controller.common.events.TorEvent +import io.matthewnelson.kmp.tor.manager.TorManager +import io.matthewnelson.kmp.tor.manager.TorServiceConfig +import io.matthewnelson.kmp.tor.manager.common.TorControlManager +import io.matthewnelson.kmp.tor.manager.common.TorOperationManager +import io.matthewnelson.kmp.tor.manager.common.event.TorManagerEvent +import io.matthewnelson.kmp.tor.manager.common.state.isOff +import io.matthewnelson.kmp.tor.manager.common.state.isOn +import io.matthewnelson.kmp.tor.manager.common.state.isStarting +import io.matthewnelson.kmp.tor.manager.common.state.isStopping +import io.matthewnelson.kmp.tor.manager.R +import kotlinx.coroutines.* +import java.net.InetSocketAddress +import java.net.Proxy +import java.util.concurrent.ExecutionException + +class TorKmp(application : Application) { + + private val TAG = "TorListener" + + private val providerAndroid by lazy { + object : TorConfigProviderAndroid(context = application) { + override fun provide(): TorConfig { + return TorConfig.Builder { + // Set multiple ports for all of the things + val dns = Ports.Dns() + put(dns.set(AorDorPort.Value(PortProxy(9252)))) + put(dns.set(AorDorPort.Value(PortProxy(9253)))) + + val socks = Ports.Socks() + put(socks.set(AorDorPort.Value(PortProxy(9254)))) + put(socks.set(AorDorPort.Value(PortProxy(9255)))) + + val http = Ports.HttpTunnel() + put(http.set(AorDorPort.Value(PortProxy(9258)))) + put(http.set(AorDorPort.Value(PortProxy(9259)))) + + val trans = Ports.Trans() + put(trans.set(AorDorPort.Value(PortProxy(9262)))) + put(trans.set(AorDorPort.Value(PortProxy(9263)))) + + // If a port (9263) is already taken (by ^^^^ trans port above) + // this will take its place and "overwrite" the trans port entry + // because port 9263 is taken. + put(socks.set(AorDorPort.Value(PortProxy(9263)))) + + // Set Flags + socks.setFlags(setOf( + Ports.Socks.Flag.OnionTrafficOnly + )).setIsolationFlags(setOf( + Ports.IsolationFlag.IsolateClientAddr, + )).set(AorDorPort.Value(PortProxy(9264))) + put(socks) + + // reset our socks object to defaults + socks.setDefault() + + // Not necessary, as if ControlPort is missing it will be + // automatically added for you; but for demonstration purposes... +// put(Ports.Control().set(AorDorPort.Auto)) + + // Use a UnixSocket instead of TCP for the ControlPort. + // + // A unix domain socket will always be preferred on Android + // if neither Ports.Control or UnixSockets.Control are provided. + put(UnixSockets.Control().set(FileSystemFile( + workDir.builder { + + // Put the file in the "data" directory + // so that we avoid any directory permission + // issues. + // + // Note that DataDirectory is automatically added + // for you if it is not present in your provided + // config. If you set a custom Path for it, you + // should use it here. + addSegment(DataDirectory.DEFAULT_NAME) + + addSegment(UnixSockets.Control.DEFAULT_NAME) + } + ))) + + // Use a UnixSocket instead of TCP for the SocksPort. + put(UnixSockets.Socks().set(FileSystemFile( + workDir.builder { + + // Put the file in the "data" directory + // so that we avoid any directory permission + // issues. + // + // Note that DataDirectory is automatically added + // for you if it is not present in your provided + // config. If you set a custom Path for it, you + // should use it here. + addSegment(DataDirectory.DEFAULT_NAME) + + addSegment(UnixSockets.Socks.DEFAULT_NAME) + } + ))) + + // For Android, disabling & reducing connection padding is + // advisable to minimize mobile data usage. + put(ConnectionPadding().set(AorTorF.False)) + put(ConnectionPaddingReduced().set(TorF.True)) + + // Tor default is 24h. Reducing to 10 min helps mitigate + // unnecessary mobile data usage. + put(DormantClientTimeout().set(Time.Minutes(10))) + + // Tor defaults this setting to false which would mean if + // Tor goes dormant, the next time it is started it will still + // be in the dormant state and will not bootstrap until being + // set to "active". This ensures that if it is a fresh start, + // dormancy will be cancelled automatically. + put(DormantCanceledByStartup().set(TorF.True)) + + // If planning to use v3 Client Authentication in a persistent + // manner (where private keys are saved to disk via the "Persist" + // flag), this is needed to be set. + put(ClientOnionAuthDir().set(FileSystemDir( + workDir.builder { addSegment(ClientOnionAuthDir.DEFAULT_NAME) } + ))) + + val hsPath = workDir.builder { + addSegment(HiddenService.DEFAULT_PARENT_DIR_NAME) + addSegment("test_service") + } + // Add Hidden services + put(HiddenService() + .setPorts(ports = setOf( + // Use a unix domain socket to communicate via IPC instead of over TCP + HiddenService.UnixSocket(virtualPort = Port(80), targetUnixSocket = hsPath.builder { + addSegment(HiddenService.UnixSocket.DEFAULT_UNIX_SOCKET_NAME) + }), + )) + .setMaxStreams(maxStreams = HiddenService.MaxStreams(value = 2)) + .setMaxStreamsCloseCircuit(value = TorF.True) + .set(FileSystemDir(path = hsPath)) + ) + + put(HiddenService() + .setPorts(ports = setOf( + HiddenService.Ports(virtualPort = Port(80), targetPort = Port(1030)), // http + HiddenService.Ports(virtualPort = Port(443), targetPort = Port(1030)) // https + )) + .set(FileSystemDir(path = + workDir.builder { + addSegment(HiddenService.DEFAULT_PARENT_DIR_NAME) + addSegment("test_service_2") + } + )) + ) + }.build() + } + } + } + + private val loaderAndroid by lazy { + KmpTorLoaderAndroid(provider = providerAndroid) + } + + private val manager: TorManager by lazy { + TorManager.newInstance(application = application, loader = loaderAndroid, requiredEvents = null) + } + + // only expose necessary interfaces + val torOperationManager: TorOperationManager get() = manager + val torControlManager: TorControlManager get() = manager + + private val listener = TorListener() + + val events: LiveData get() = listener.eventLines + + private val appScope by lazy { + CoroutineScope(Dispatchers.Main.immediate + SupervisorJob()) + } + + val torStateLiveData: MutableLiveData = MutableLiveData() + get() = field + var torState: TorState = TorState() + get() = field + + var proxy: Proxy? = null + get() = field + + init { + manager.debug(true) + manager.addListener(listener) + listener.addLine(TorServiceConfig.getMetaData(application).toString()) + } + + fun isConnected(): Boolean { + return manager.state.isOn() && manager.state.bootstrap >= 100 + } + + fun isStarting(): Boolean { + return manager.state.isStarting() || + (manager.state.isOn() && manager.state.bootstrap < 100); + } + + + fun newIdentity(appContext: Application) { + appScope.launch { + val result = manager.signal(TorControlSignal.Signal.NewNym) + result.onSuccess { + if (it !is String) { + listener.addLine(TorControlSignal.NEW_NYM_SUCCESS) + Toast.makeText(appContext, TorControlSignal.NEW_NYM_SUCCESS, Toast.LENGTH_SHORT).show() + return@onSuccess + } + + val post: String? = when { + it.startsWith(TorControlSignal.NEW_NYM_RATE_LIMITED) -> { + // Rate limiting NEWNYM request: delaying by 8 second(s) + val seconds: Int? = it.drop(TorControlSignal.NEW_NYM_RATE_LIMITED.length) + .substringBefore(' ') + .toIntOrNull() + + if (seconds == null) { + it + } else { + appContext.getString( + R.string.kmp_tor_newnym_rate_limited, + seconds + ) + } + } + it == TorControlSignal.NEW_NYM_SUCCESS -> { + appContext.getString(R.string.kmp_tor_newnym_success) + } + else -> { + null + } + } + + if (post != null) { + listener.addLine(post) + Toast.makeText(appContext, post, Toast.LENGTH_SHORT).show() + } + } + result.onFailure { + val msg = "Tor identity change failed" + listener.addLine(msg) + Toast.makeText(appContext, msg, Toast.LENGTH_SHORT).show() + } + } + } + + + private inner class TorListener: TorManagerEvent.Listener() { + private val _eventLines: MutableLiveData = MutableLiveData("") + val eventLines: LiveData = _eventLines + private val events: MutableList = ArrayList(50) + fun addLine(line: String) { + synchronized(this) { + if (events.size > 49) { + events.removeAt(0) + } + events.add(line) + //Log.i(TAG, line) + //_eventLines.value = events.joinToString("\n") + _eventLines.postValue(events.joinToString("\n")) + } + } + + override fun onEvent(event: TorManagerEvent) { + + if (event is TorManagerEvent.State) { + val stateEvent: TorManagerEvent.State = event + val state = stateEvent.torState + torState.progressIndicator = state.bootstrap + val liveTorState = TorState() + liveTorState.progressIndicator = state.bootstrap + + if (state.isOn()) { + if (state.bootstrap >= 100) { + torState.state = EnumTorState.ON + liveTorState.state = EnumTorState.ON + } else { + torState.state = EnumTorState.STARTING + liveTorState.state = EnumTorState.STARTING + } + } else if (state.isStarting()) { + torState.state = EnumTorState.STARTING + liveTorState.state = EnumTorState.STARTING + } else if (state.isOff()) { + torState.state = EnumTorState.OFF + liveTorState.state = EnumTorState.OFF + } else if (state.isStopping()) { + torState.state = EnumTorState.STOPPING + liveTorState.state = EnumTorState.STOPPING + } + torStateLiveData.postValue(liveTorState) + } + addLine(event.toString()) + super.onEvent(event) + } + + override fun onEvent(event: TorEvent.Type.SingleLineEvent, output: String) { + addLine("$event - $output") + + super.onEvent(event, output) + } + + override fun onEvent(event: TorEvent.Type.MultiLineEvent, output: List) { + addLine("multi-line event: $event. See Logs.") + + // these events are many many many lines and should be moved + // off the main thread if ever needed to be dealt with. + val enabled = false + if (enabled) { + appScope.launch(Dispatchers.IO) { + Log.d(TAG, "-------------- multi-line event START: $event --------------") + for (line in output) { + Log.d(TAG, line) + } + Log.d(TAG, "--------------- multi-line event END: $event ---------------") + } + } + + super.onEvent(event, output) + } + + override fun managerEventError(t: Throwable) { + t.printStackTrace() + } + + override fun managerEventAddressInfo(info: TorManagerEvent.AddressInfo) { + if (info.isNull) { + // Tear down HttpClient + } else { + info.socksInfoToProxyAddressOrNull()?.firstOrNull()?.let { proxyAddress -> + @Suppress("UNUSED_VARIABLE") + val socket = InetSocketAddress(proxyAddress.address.value, proxyAddress.port.value) + proxy = Proxy(Proxy.Type.SOCKS, socket) + } + } + } + + override fun managerEventStartUpCompleteForTorInstance() { + // Do one-time things after we're bootstrapped + + appScope.launch { + torControlManager.onionAddNew( + type = OnionAddress.PrivateKey.Type.ED25519_V3, + hsPorts = setOf(HiddenService.Ports(virtualPort = Port(443))), + flags = null, + maxStreams = null, + ).onSuccess { hsEntry -> + addLine( + "New HiddenService: " + + "\n - Address: https://${hsEntry.address.canonicalHostname()}" + + "\n - PrivateKey: ${hsEntry.privateKey}" + ) + + torControlManager.onionDel(hsEntry.address).onSuccess { + addLine("Aaaaaaaaand it's gone...") + }.onFailure { t -> + t.printStackTrace() + } + }.onFailure { t -> + t.printStackTrace() + } + + delay(20_000L) + + torControlManager.infoGet(TorControlInfoGet.KeyWord.Uptime()).onSuccess { uptime -> + addLine("Uptime - $uptime") + }.onFailure { t -> + t.printStackTrace() + } + } + } + } +} + +object TorKmpManager { + private lateinit var torKmp: TorKmp + + @Throws(UninitializedPropertyAccessException::class) + fun getTorKmpObject(): TorKmp { + return torKmp + } + + fun updateTorKmpObject(newKmpObject: TorKmp) { + torKmp = newKmpObject + } +} diff --git a/mobile_new/app/src/main/java/com/koalasat/robosats/tor/TorState.kt b/mobile_new/app/src/main/java/com/koalasat/robosats/tor/TorState.kt new file mode 100644 index 00000000..51052571 --- /dev/null +++ b/mobile_new/app/src/main/java/com/koalasat/robosats/tor/TorState.kt @@ -0,0 +1,14 @@ +package com.robosats.tor + +class TorState { + var state : EnumTorState = EnumTorState.OFF + get() = field + set(value) { + field = value + } + var progressIndicator : Int = 0 + get() = field + set(value) { + field = value + } +} diff --git a/mobile_new/app/src/main/res/drawable/ic_launcher_background.xml b/mobile_new/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/mobile_new/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile_new/app/src/main/res/drawable/ic_launcher_foreground.xml b/mobile_new/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..7706ab9e --- /dev/null +++ b/mobile_new/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + diff --git a/mobile_new/app/src/main/res/layout/activity_main.xml b/mobile_new/app/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..6dd3b647 --- /dev/null +++ b/mobile_new/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/mobile_new/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/mobile_new/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..b3e26b4c --- /dev/null +++ b/mobile_new/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile_new/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/mobile_new/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..b3e26b4c --- /dev/null +++ b/mobile_new/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile_new/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/mobile_new/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..c209e78e Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/mobile_new/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..b2dfe3d1 Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/mobile_new/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..4f0f1d64 Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/mobile_new/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..62b611da Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/mobile_new/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..948a3070 Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/mobile_new/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1b9a6956 Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/mobile_new/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..28d4b77f Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/mobile_new/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9287f508 Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/mobile_new/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..aa7d6427 Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/mobile_new/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/mobile_new/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..9126ae37 Binary files /dev/null and b/mobile_new/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/mobile_new/app/src/main/res/values-night/themes.xml b/mobile_new/app/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..70efe437 --- /dev/null +++ b/mobile_new/app/src/main/res/values-night/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/mobile_new/app/src/main/res/values/colors.xml b/mobile_new/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..768b058a --- /dev/null +++ b/mobile_new/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #FFFFFFFF + diff --git a/mobile_new/app/src/main/res/values/strings.xml b/mobile_new/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..b1a28134 --- /dev/null +++ b/mobile_new/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Robosats + \ No newline at end of file diff --git a/mobile_new/app/src/main/res/values/themes.xml b/mobile_new/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..11b4ad52 --- /dev/null +++ b/mobile_new/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + +