diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index ff16e8d8..729f6a19 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -30,165 +30,146 @@ jobs: - name: 'Checkout' uses: actions/checkout@v4 - # - name: 'Download Android Web.bundle Artifact (built frontend)' - # if: inputs.semver == '' # Only if workflow fired from frontend-build.yml - # uses: dawidd6/action-download-artifact@v11 - # with: - # workflow: frontend-build.yml - # workflow_conclusion: success - # name: mobile-web.bundle - # path: mobile/html/Web.bundle + - name: 'Download Android Web.bundle Artifact (built frontend)' + if: inputs.semver == '' # Only if workflow fired from frontend-build.yml + uses: dawidd6/action-download-artifact@v11 + with: + workflow: frontend-build.yml + workflow_conclusion: success + name: mobile-web.bundle + path: android/app/src/main/assets - # - name: 'Download main.js Artifact for a release' - # if: inputs.semver != '' # Only if fired as job in release.yml - # uses: actions/download-artifact@v4 - # with: - # name: mobile-web.bundle - # path: mobile/html/Web.bundle + - name: 'Download main.js Artifact for a release' + if: inputs.semver != '' # Only if fired as job in release.yml + uses: actions/download-artifact@v4 + with: + name: mobile-web.bundle + path: android/app/src/main/assets - # - name: 'Install npm Dependencies' - # run: | - # cd mobile - # npm install + - name: Cache gradle + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- - # - name: 'Patch modules' # react-native-tor and react-native-encrypted-storage rely on deprecated jcenter repositories. We patch the modules temporarily - # run: | - # cd mobile - # cp -r patch_modules/* node_modules/ - # - uses: actions/setup-java@v4 - # with: - # distribution: temurin - # java-version: 11 + - name: Build APK + run: | + cd android + ./gradlew assembleRelease --stacktrace - # - name: Setup Gradle - # uses: gradle/gradle-build-action@v3 + - name: 'Check for non-FOSS libraries' + run: | + wget https://github.com/iBotPeaches/Apktool/releases/download/v2.7.0/apktool_2.7.0.jar + wget https://github.com/iBotPeaches/Apktool/raw/master/scripts/linux/apktool + # clone the repo + git clone https://gitlab.com/IzzyOnDroid/repo.git + # create a directory for Apktool and move the apktool* files there + mkdir -p repo/lib/radar/tool + mv apktool* repo/lib/radar/tool + # create an alias for ease of use + chmod u+x repo/lib/radar/tool/apktool + mv repo/lib/radar/tool/apktool_2.7.0.jar repo/lib/radar/tool/apktool.jar + repo/bin/scanapk.php android/app/build/outputs/apk/release/app-universal-release-unsigned.apk - # - name: Decode Keystore - # id: decode_keystore - # uses: timheuer/base64-to-file@v1.2 - # with: - # fileName: 'keystore.jks' - # fileDir: './' - # encodedString: ${{ secrets.KEYSTORE }} + - name: Sign APK + uses: r0adkll/sign-android-release@v1 + with: + releaseDirectory: android/app/build/outputs/apk/release + signingKeyBase64: ${{ secrets.KEYSTORE }} + alias: ${{ secrets.KEY_ALIAS }} + keyStorePassword: ${{ secrets.KEY_STORE_PASS }} + keyPassword: ${{ secrets.KEY_PASS }} + env: + BUILD_TOOLS_VERSION: "34.0.0" - # - name: 'Build Android Release' - # run: | - # cd mobile/android - # ./gradlew assembleRelease - # env: - # KEY_ALIAS: ${{ secrets.KEY_ALIAS }} - # KEY_PASS: ${{ secrets.KEY_PASS }} - # KEY_STORE_PASS: ${{ secrets.KEY_STORE_PASS }} + - uses: kaisugi/action-regex-match@v1.0.1 + id: regex-match + with: + text: ${{ github.ref }} + regex: '(v*-pre*)' + flags: gm + - name: 'Get Commit Hash' + id: commit + uses: pr-mpt/actions-commit-hash@v3 - # - name: 'Check for non-FOSS libraries' - # run: | - # wget https://github.com/iBotPeaches/Apktool/releases/download/v2.7.0/apktool_2.7.0.jar - # wget https://github.com/iBotPeaches/Apktool/raw/master/scripts/linux/apktool - # # clone the repo - # git clone https://gitlab.com/IzzyOnDroid/repo.git - # # create a directory for Apktool and move the apktool* files there - # mkdir -p repo/lib/radar/tool - # mv apktool* repo/lib/radar/tool - # # create an alias for ease of use - # chmod u+x repo/lib/radar/tool/apktool - # mv repo/lib/radar/tool/apktool_2.7.0.jar repo/lib/radar/tool/apktool.jar - # repo/bin/scanapk.php mobile/android/app/build/outputs/apk/release/app-universal-release.apk + # Create app-universal-release APK artifact asset for Release + - name: 'Upload universal .apk Artifact' + if: inputs.semver != '' + uses: actions/upload-artifact@v4 + with: + name: robosats-${{ inputs.semver }}-universal.apk + path: android/app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk - # - name: 'Get Commit Hash' - # id: commit - # uses: pr-mpt/actions-commit-hash@v3 + # Create app-arm64-v8a-release APK artifact asset for Release + - name: 'Upload arm64-v8a .apk Artifact' + if: inputs.semver != '' + uses: actions/upload-artifact@v4 + with: + name: robosats-${{ inputs.semver }}-arm64-v8a.apk + path: android/app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk - # # Create artifacts (only for Release) - # # Create app-universal-release APK artifact asset for Release - # - name: 'Upload universal .apk Release Artifact (for Release)' - # uses: actions/upload-artifact@v4 - # if: inputs.semver != '' # If this workflow is called from release.yml - # with: - # name: robosats-${{ inputs.semver }}-universal.apk - # path: mobile/android/app/build/outputs/apk/release/app-universal-release.apk + # Create app-armeabi-v7a-release APK artifact asset for Release + - name: 'Upload armeabi-v7a .apk Artifact' + if: inputs.semver != '' + uses: actions/upload-artifact@v4 + with: + name: robosats-${{ inputs.semver }}-armeabi-v7a.apk + path: android/app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk - # # Create app-arm64-v8a-release APK artifact asset for Release - # - name: 'Upload arm64-v8a .apk Release Artifact (for Release)' - # uses: actions/upload-artifact@v4 - # if: inputs.semver != '' # If this workflow is called from release.yml - # with: - # name: robosats-${{ inputs.semver }}-arm64-v8a.apk - # path: mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk + # Create app-x86_64-release APK artifact asset for Release + - name: 'Upload x86_64 .apk Artifact' + if: inputs.semver != '' + uses: actions/upload-artifact@v4 + with: + name: robosats-${{ inputs.semver }}-x86_64.apk + path: android/app/build/outputs/apk/release/app-x86_64-release-unsigned-signed.apk - # # Create app-armeabi-v7a-release APK artifact asset for Release - # - name: 'Upload armeabi-v7a .apk Release Artifact (for Release)' - # uses: actions/upload-artifact@v4 - # if: inputs.semver != '' # If this workflow is called from release.yml - # with: - # name: robosats-${{ inputs.semver }}-armeabi-v7a.apk - # path: mobile/android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk + - name: Create Pre-Release + id: create_release + if: inputs.semver == '' # only if this workflow is not called from a push to tag (a Release) + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: ${{ github.ref }} + draft: true - # # Create app-x86_64-release APK artifact asset for Release - # - name: 'Upload x86_64 .apk Release Artifact (for Release)' - # uses: actions/upload-artifact@v4 - # if: inputs.semver != '' # If this workflow is called from release.yml - # with: - # name: robosats-${{ inputs.semver }}-x86_64.apk - # path: mobile/android/app/build/outputs/apk/release/app-x86_64-release.apk + - name: Upload APK Universal Asset + id: upload-release-asset-universal-apk + if: inputs.semver == '' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: android/app/build/outputs/apk/release/app-universal-release-unsigned-signed.apk + asset_name: robosats-${{ github.ref }}-universal.apk + asset_content_type: application/zip - # - name: 'Create Pre-release' - # id: create_release - # if: inputs.semver == '' # only if this workflow is not called from a push to tag (a Release) - # uses: ncipollo/release-action@v1.18.0 - # with: - # tag: android-${{ steps.commit.outputs.short }} - # name: robosats-android-${{ steps.commit.outputs.short }} - # prerelease: true + - name: Upload APK arm64-v8a Asset + id: upload-release-asset-arm64-v8a + if: inputs.semver == '' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: android/app/build/outputs/apk/release/app-arm64-v8a-release-unsigned-signed.apk + asset_name: robosats-${{ github.ref }}-arm64-v8a.apk + asset_content_type: application/zip - # # Upload universal APK to pre-release - # - name: 'Upload universal Pre-release APK Asset' - # id: upload-release-universal-apk-asset - # if: inputs.semver == '' # only if this workflow is not called from a push to tag (a Release) - # uses: actions/upload-release-asset@v1 - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # with: - # upload_url: ${{ steps.create_release.outputs.upload_url }} - # asset_path: ./mobile/android/app/build/outputs/apk/release/app-universal-release.apk - # asset_name: robosats-${{ steps.commit.outputs.short }}-universal.apk - # asset_content_type: application/apk - - # # Upload arm64-v8a APK to pre-release - # - name: 'Upload arm64-v8a Pre-release APK Asset' - # id: upload-release-arm64-v8a-apk-asset - # if: inputs.semver == '' # only if this workflow is not called from a push to tag (a Release) - # uses: actions/upload-release-asset@v1 - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # with: - # upload_url: ${{ steps.create_release.outputs.upload_url }} - # asset_path: ./mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk - # asset_name: robosats-${{ steps.commit.outputs.short }}-arm64-v8a.apk - # asset_content_type: application/apk - - # # Upload armeabi-v7a APK to pre-release - # - name: 'Upload armeabi-v7a Pre-release APK Asset' - # id: upload-release-armeabi-v7a-apk-asset - # if: inputs.semver == '' # only if this workflow is not called from a push to tag (a Release) - # uses: actions/upload-release-asset@v1 - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # with: - # upload_url: ${{ steps.create_release.outputs.upload_url }} - # asset_path: ./mobile/android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk - # asset_name: robosats-${{ steps.commit.outputs.short }}-armeabi-v7a.apk - # asset_content_type: application/apk - - # # Upload x86_64 APK to pre-release - # - name: 'Upload x86_64 Pre-release APK Asset' - # id: upload-release-x86_64-apk-asset - # if: inputs.semver == '' # only if this workflow is not called from a push to tag (a Release) - # uses: actions/upload-release-asset@v1 - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # with: - # upload_url: ${{ steps.create_release.outputs.upload_url }} - # asset_path: ./mobile/android/app/build/outputs/apk/release/app-x86_64-release.apk - # asset_name: robosats-${{ steps.commit.outputs.short }}-x86_64.apk - # asset_content_type: application/apk + - name: Upload APK armeabi-v7a Asset + id: upload-release-asset-armeabi-v7a + if: inputs.semver == '' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: android/app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned-signed.apk + asset_name: robosats-${{ github.ref }}-armeabi-v7a.apk + asset_content_type: application/zip diff --git a/.github/workflows/frontend-build.yml b/.github/workflows/frontend-build.yml index a3008d85..ee38a7d1 100644 --- a/.github/workflows/frontend-build.yml +++ b/.github/workflows/frontend-build.yml @@ -73,11 +73,11 @@ jobs: path: | web/static web/*.html - # - name: 'Archive Mobile Build Results' - # uses: actions/upload-artifact@v4 - # with: - # name: mobile-web.bundle - # path: mobile/html/Web.bundle + - name: 'Archive Mobile Build Results' + uses: actions/upload-artifact@v4 + with: + name: mobile-web.bundle + path: android/app/src/main/assets # Invoke pre-release image build if this was not a tag push # Docker images tagged only with short commit hash diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 262fd95f..e647da3b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.robosats" minSdk = 24 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + versionCode = 15 + versionName = "0.8.1-alpha" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android/app/src/main/java/com/robosats/MainActivity.kt b/android/app/src/main/java/com/robosats/MainActivity.kt index ed8cba8f..41e02b56 100644 --- a/android/app/src/main/java/com/robosats/MainActivity.kt +++ b/android/app/src/main/java/com/robosats/MainActivity.kt @@ -2,8 +2,7 @@ package com.robosats import android.annotation.SuppressLint import android.app.Application -import android.content.Context -import android.graphics.Bitmap +import android.content.pm.ActivityInfo import android.os.Build import android.os.Bundle import android.util.Log @@ -14,7 +13,6 @@ import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest import android.webkit.ServiceWorkerController import android.webkit.WebChromeClient -import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings @@ -26,11 +24,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.constraintlayout.widget.ConstraintLayout import com.robosats.tor.TorKmp import com.robosats.tor.TorKmpManager -import java.io.ByteArrayInputStream -import java.net.HttpURLConnection -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.URL class MainActivity : AppCompatActivity() { private lateinit var webView: WebView @@ -38,16 +31,12 @@ class MainActivity : AppCompatActivity() { private lateinit var loadingContainer: ConstraintLayout private lateinit var statusTextView: TextView - // Security constants - private val ALLOWED_DOMAINS = arrayOf(".onion") - private val CONTENT_SECURITY_POLICY = "default-src 'self'; connect-src 'self' https://*.onion http://*.onion; " + - "script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; " + - "img-src 'self' data:; font-src 'self' data:; object-src 'none'; " + - "media-src 'none'; frame-src 'none'; worker-src 'self';" - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Lock the screen orientation to portrait mode + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + // We don't need edge-to-edge since we're using fitsSystemWindows setContentView(R.layout.activity_main) @@ -158,7 +147,7 @@ class MainActivity : AppCompatActivity() { return } - // IMMEDIATELY set a blocking WebViewClient to prevent ANY network access + // 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 @@ -166,7 +155,7 @@ class MainActivity : AppCompatActivity() { } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { - // Block ALL URL loading attempts until proxy is properly configured + // Block ALL URL loading attempts return true } } @@ -187,24 +176,12 @@ class MainActivity : AppCompatActivity() { 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("WebViewProxy", "Using proxy settings: $proxyHost:$proxyPort") - // Success - now configure WebViewClient and load URL on UI thread runOnUiThread { updateStatus("Secure connection established. Loading app...") @@ -233,237 +210,6 @@ class MainActivity : AppCompatActivity() { } } - // 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("WebViewProxy", "Intercepting request: $urlString") - - // Block all external requests that aren't to .onion domains or local files - if (!isAllowedRequest(urlString)) { - Log.e("SecurityPolicy", "Blocked forbidden request to: $urlString") - return WebResourceResponse("text/plain", "UTF-8", null) - } - - try { - // Special handling for .onion domains - val isOnionDomain = urlString.contains(".onion") - - // Only proceed if it's an onion domain or local file - if (!isOnionDomain && !urlString.startsWith("file://")) { - Log.e("SecurityPolicy", "Blocked non-onion external request: $urlString") - return WebResourceResponse("text/plain", "UTF-8", null) - } - - // For .onion domains, we must use SOCKS proxy type - val proxyType = if (isOnionDomain) - Proxy.Type.SOCKS - else - Proxy.Type.HTTP - - // Create a proxy instance for Tor with the appropriate type - val torProxy = Proxy( - proxyType, - InetSocketAddress(proxyHost, proxyPort) - ) - - if (isOnionDomain) { - Log.d("WebViewProxy", "Handling .onion domain with SOCKS proxy: $urlString") - } - - // If it's a local file, return it directly - if (urlString.startsWith("file://")) { - // Let the system handle local files - return super.shouldInterceptRequest(view, request) - } - - // Create connection with proxy already configured - val url = 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 HttpURLConnection) { - // Ensure no connection reuse to prevent proxy leaks - connection.setRequestProperty("Connection", "close") - - // Add security headers - connection.setRequestProperty("Sec-Fetch-Site", "same-origin") - connection.setRequestProperty("Sec-Fetch-Mode", "cors") - connection.setRequestProperty("DNT", "1") // Do Not Track - - // Copy request headers - request.requestHeaders.forEach { (key, value) -> - connection.setRequestProperty(key, value) - } - - // Set the request method - connection.requestMethod = request.method - - // Special handling for OPTIONS (CORS preflight) requests - if (request.method == "OPTIONS") { - // For OPTIONS, we'll create a custom response without making a network request - // This is the most reliable way to handle CORS preflight - Log.d("CORS", "Handling OPTIONS preflight request for: $urlString") - - // Create CORS headers map - val preflightHeaders = HashMap() - preflightHeaders["Access-Control-Allow-Origin"] = "*" - preflightHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE, HEAD" - preflightHeaders["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept, Authorization" - preflightHeaders["Access-Control-Max-Age"] = "86400" // Cache preflight for 24 hours - preflightHeaders["Access-Control-Allow-Credentials"] = "true" - preflightHeaders["Content-Type"] = "text/plain" - - // Log CORS headers for debugging - Log.d("CORS", "Preflight response with CORS headers: $preflightHeaders") - - // Return a custom preflight response without actually connecting - return WebResourceResponse( - "text/plain", - "UTF-8", - 200, - "OK", - preflightHeaders, - ByteArrayInputStream("".toByteArray()) - ) - } - - // 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("WebViewProxy", "Successfully proxied request to $url (HTTP ${connection.responseCode})") - - // Get the correct input stream based on response code - val inputStream = if (responseCode >= 400) { - connection.errorStream ?: ByteArrayInputStream(byteArrayOf()) - } else { - connection.inputStream - } - - // Create response headers map with security headers - val responseHeaders = HashMap() - - // First copy original response headers, but carefully handle CORS headers - for (i in 0 until connection.headerFields.size) { - val key = connection.headerFields.keys.elementAtOrNull(i) - if (key != null && key.isNotEmpty()) { - // Skip any CORS headers from the original response - we'll add our own - if (!key.startsWith("Access-Control-")) { - val value = connection.getHeaderField(key) - if (value != null) { - responseHeaders[key] = value - } - } else { - // Log any CORS headers we're skipping from the original response - Log.d("CORS", "Skipping original CORS header: $key: ${connection.getHeaderField(key)}") - } - } - } - - // Add our own CORS headers - responseHeaders["Access-Control-Allow-Origin"] = "*" - responseHeaders["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS, PUT, DELETE, HEAD" - responseHeaders["Access-Control-Allow-Headers"] = "Origin, X-Requested-With, Content-Type, Accept, Authorization" - responseHeaders["Access-Control-Allow-Credentials"] = "true" - if (!responseHeaders.containsKey("Content-Security-Policy")) { - responseHeaders["Content-Security-Policy"] = CONTENT_SECURITY_POLICY - } - if (!responseHeaders.containsKey("X-Content-Type-Options")) { - responseHeaders["X-Content-Type-Options"] = "nosniff" - } - if (!responseHeaders.containsKey("X-Frame-Options")) { - responseHeaders["X-Frame-Options"] = "DENY" - } - if (!responseHeaders.containsKey("Referrer-Policy")) { - responseHeaders["Referrer-Policy"] = "no-referrer" - } - - // Log the CORS headers for debugging - responseHeaders["Access-Control-Allow-Origin"]?.let { - Log.d("CORS", "Access-Control-Allow-Origin: $it") - } - - // Return proxied response with security headers - return WebResourceResponse( - mimeType, - encoding, - responseCode, - "OK", - responseHeaders, - inputStream - ) - } else { - // For non-HTTP connections (rare) - val inputStream = connection.getInputStream() - Log.d("WebViewProxy", "Successfully established non-HTTP connection to $url") - return WebResourceResponse( - "application/octet-stream", - "UTF-8", - inputStream - ) - } - } catch (e: Exception) { - Log.e("WebViewProxy", "Error proxying request: $urlString - ${e.message}", e) - - // For security, block the request rather than falling back to system handling - return WebResourceResponse("text/plain", "UTF-8", null) - } - } - - // We're not handling SSL, so we don't need the onReceivedSslError method - - 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: 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) - } - } - webView.settings.userAgentString = "AndroidRobosats" // Add the JavaScript interface @@ -488,46 +234,6 @@ class MainActivity : AppCompatActivity() { }.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 - */ /** * Configure WebView settings with a security-first approach */ @@ -597,24 +303,6 @@ class MainActivity : AppCompatActivity() { webSettings.textZoom = 100 } - /** - * Check if a URL request is allowed based on security policy - */ - private fun isAllowedRequest(url: String): Boolean { - // Always allow local file requests - if (url.startsWith("file:///android_asset/") || url.startsWith("file:///data/")) { - return true - } - - // Allow onion domains - if (ALLOWED_DOMAINS.any { url.contains(it) }) { - return true - } - - // Block everything else - return false - } - // SSL error description method removed as we're not using SSL /** @@ -638,57 +326,4 @@ class MainActivity : AppCompatActivity() { super.onDestroy() } - - 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(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/android/app/src/main/java/com/robosats/WebAppInterface.kt b/android/app/src/main/java/com/robosats/WebAppInterface.kt index 177cb812..bd32caf3 100644 --- a/android/app/src/main/java/com/robosats/WebAppInterface.kt +++ b/android/app/src/main/java/com/robosats/WebAppInterface.kt @@ -7,18 +7,24 @@ import android.webkit.JavascriptInterface import android.webkit.WebView import android.widget.Toast import com.robosats.tor.TorKmpManager.getTorKmpObject +import okhttp3.Call +import okhttp3.Callback +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.OkHttpClient.Builder import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.ByteString -import java.util.Objects +import org.json.JSONObject +import java.io.IOException import java.util.concurrent.TimeUnit import java.util.regex.Pattern import okhttp3.Request.Builder as RequestBuilder + /** * Provides a secure bridge between JavaScript and native Android code. * This class is designed with security in mind, implementing input validation, @@ -238,6 +244,80 @@ class WebAppInterface(private val context: Context, private val webView: WebView } } + @JavascriptInterface + fun sendRequest(uuid: String, action: String, url: String, headers: String, body: String) { + // Validate inputs + if (!isValidUuid(uuid)) { + Log.e(TAG, "Invalid UUID for sendRequest: $uuid") + rejectPromise(uuid, "Invalid UUID") + return + } + + try { + // Create OkHttpClient with Tor proxy + val client = Builder() + .connectTimeout(60, TimeUnit.SECONDS) // Set connection timeout + .readTimeout(30, TimeUnit.SECONDS) // Set read timeout + .proxy(getTorKmpObject().proxy) + .build() + + // Build request with URL + val requestBuilder = RequestBuilder().url(url) + + // Add headers from JSON + val headersObject = JSONObject(headers) + val keys = headersObject.keys() + while (keys.hasNext()) { + val key = keys.next() + val value = headersObject.optString(key) + requestBuilder.addHeader(key, value) + } + + // Set request method and body + when (action) { + "DELETE" -> requestBuilder.delete() + "POST" -> { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = body.toRequestBody(mediaType) + requestBuilder.post(requestBody) + } + else -> requestBuilder.get() + } + + // Build and execute request + val request = requestBuilder.build() + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d("RobosatsError", e.toString()) + rejectPromise(uuid, "Request failed: ${e.message}") + } + + override fun onResponse(call: Call, response: Response) { + try { + // Get response body + val responseBody = response.body.string() + + // Create JSON object with headers + val headersJson = JSONObject() + response.headers.names().forEach { name -> + headersJson.put(name, response.header(name)) + } + + // Return response as JSON string + val result = "{\"json\":$responseBody, \"headers\": $headersJson}" + resolvePromise(uuid, result) + } catch (e: Exception) { + Log.e(TAG, "Error processing response", e) + rejectPromise(uuid, "Error processing response: ${e.message}") + } + } + }) + } catch (e: Exception) { + Log.e(TAG, "Error in sendRequest", e) + rejectPromise(uuid, "Error sending request: ${e.message}") + } + } + private fun onWsMessage(path: String?, message: String?) { safeEvaluateJavascript("javascript:window.AndroidRobosats.onWSMessage('$path', '$message')") } diff --git a/android/app/src/main/java/com/robosats/tor/TorKmpManager.kt b/android/app/src/main/java/com/robosats/tor/TorKmpManager.kt index 9accff0c..3c3b1164 100644 --- a/android/app/src/main/java/com/robosats/tor/TorKmpManager.kt +++ b/android/app/src/main/java/com/robosats/tor/TorKmpManager.kt @@ -23,11 +23,9 @@ 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) { @@ -234,14 +232,11 @@ class TorKmp(application : Application) { if (seconds == null) { it } else { - appContext.getString( - R.string.kmp_tor_newnym_rate_limited, - seconds - ) + TorControlSignal.NEW_NYM_RATE_LIMITED } } it == TorControlSignal.NEW_NYM_SUCCESS -> { - appContext.getString(R.string.kmp_tor_newnym_success) + TorControlSignal.NEW_NYM_SUCCESS } else -> { null diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index b1a28134..d9f62954 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ Robosats - \ No newline at end of file + diff --git a/android/zapstore.yaml b/android/zapstore.yaml new file mode 100644 index 00000000..f41b3e7c --- /dev/null +++ b/android/zapstore.yaml @@ -0,0 +1,46 @@ +name: Robosats +summary: A simple and private bitcoin exchange +description: >- + RoboSats is a simple and private app to exchange bitcoin for national currencies. + Robosats simplifies the P2P user experience and uses lightning hold invoices to + minimize custody and trust requirements. The deterministically generated robot + avatars help users stick to best privacy practices. + + Features: + + - Decentralized: Scrow services are individually provided by members of the Robosats federation. + + - Privacy focused: your robot avatar is deterministically generated, no need for registration. + + - More than 10 languages available and over 60 fiat currencies + + - Safe: simply lock a lightning hodl invoice and show you are real and committed. + + - No data collection. Your communication with your peer is PGP encrypted, only you can read it. + + - Lightning fast: the average sovereign trade finishes in ~ 8 minutes. Faster than a single block confirmation! + + - Fully collateralized escrow: your peer is always committed and cannot run away with the funds. + + - Strong incentives system: attempts of cheating are penalized with the slashing of the Sats in the fidelity bond. + + - Guides and video tutorials available at https://learn.robosats.com/watch/en + + You can join other cool Robots and get community support at https://learn.robosats.org. +repository: https://github.com/RoboSats/robosats +images: + - ../../fastlane/metadata/en-US/images/phoneScreenshots/01.jpg + - ../../fastlane/metadata/en-US/images/phoneScreenshots/02.jpg + - ../../fastlane/metadata/en-US/images/phoneScreenshots/03.jpg + - ../../fastlane/metadata/en-US/images/phoneScreenshots/04.jpg + - ../../fastlane/metadata/en-US/images/phoneScreenshots/05.jpg + - ../../fastlane/metadata/en-US/images/phoneScreenshots/06.jpg +icon: ../../fastlane/metadata/en-US/images/icon.png +tags: privacy lightning bitcoin p2p tor exchange foss +license: AGPL-3.0 +remote_metadata: + - github +assets: # for github + - robosats-v\d+\.\d+\.\d+.\w+-universal.apk + - robosats-v\d+\.\d+\.\d+.\w+-arm64-v8a.apk + - robosats-v\d+\.\d+\.\d+.\w+-armeabi-v7a.apk diff --git a/fastlane/metadata/en-US/full_description.txt b/fastlane/metadata/en-US/full_description.txt index b049c155..00491fa4 100644 --- a/fastlane/metadata/en-US/full_description.txt +++ b/fastlane/metadata/en-US/full_description.txt @@ -1,5 +1,6 @@

RoboSats is a simple and private app to exchange bitcoin for national currencies. Robosats simplifies the P2P user experience and uses lightning hold invoices to minimize custody and trust requirements. The deterministically generated robot avatars help users stick to best privacy practices.


Features:

-

You can join other cool Robots and get community support at our Telegram group.

\ No newline at end of file +

You can join other cool Robots and get community support at https://learn.robosats.org.

\ No newline at end of file diff --git a/frontend/src/basic/RobotPage/RobotProfile.tsx b/frontend/src/basic/RobotPage/RobotProfile.tsx index d62ee9ef..968b6d3b 100644 --- a/frontend/src/basic/RobotPage/RobotProfile.tsx +++ b/frontend/src/basic/RobotPage/RobotProfile.tsx @@ -41,7 +41,7 @@ const RobotProfile = ({ setView, width, }: RobotProfileProps): React.JSX.Element => { - const { windowSize, client, setOpen, open, navigateToPage } = + const { windowSize, setOpen, open, navigateToPage, client } = useContext(AppContext); const { garage, slotUpdatedAt } = useContext(GarageContext); const { federation } = useContext(FederationContext); @@ -226,7 +226,7 @@ const RobotProfile = ({