diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ae962d846..21a791fb30 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,34 +1,34 @@ name: "Gradle build" permissions: {} on: - - push - - pull_request - - workflow_dispatch + push: + branches: [main, master] + pull_request: + workflow_dispatch: + inputs: + ref: + description: "Branch, tag, or commit SHA to build (defaults to the current branch)" + required: false + default: "" jobs: build: - name: "Gradle build ${{ matrix.target }}" + name: "Build Remote DroidGuard Server (debug)" runs-on: ubuntu-latest env: GRADLE_MICROG_VERSION_WITHOUT_GIT: 1 - strategy: - matrix: - target: [Debug, Release] - steps: - name: "Free disk space" run: | - # Deleting unneeded software packages sudo rm -rf /opt/hostedtoolcache/CodeQL sudo rm -rf /opt/hostedtoolcache/go - - # Log available space df -h - name: "Checkout sources" uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ inputs.ref || github.ref }} - name: "Setup Java" uses: actions/setup-java@v5 with: @@ -41,24 +41,11 @@ jobs: build-scan-publish: true build-scan-terms-of-use-url: "https://gradle.com/help/legal-terms-of-use" build-scan-terms-of-use-agree: "yes" - - name: "Setup matchers" - run: | - # Setting up matchers... - - matchers_dir='${{ github.workspace }}/.github/matchers' - matcher_list() - { - echo 'gradle-build-matcher.json' - echo 'gradle-build-kotlin-error-matcher.json' - } - - matcher_list | while IFS='' read -r NAME; do - if test -f "${matchers_dir:?}/${NAME:?}"; then - echo "::add-matcher::${matchers_dir:?}/${NAME:?}" - echo "Matcher configured: ${NAME:?}" - fi - done - - name: "Execute Gradle assemble" - run: "./gradlew assemble${{ matrix.target }}" - - name: "Execute Gradle lint" - run: "./gradlew lint${{ matrix.target }}" + - name: "Assemble debug APK" + run: "./gradlew :remote-droidguard-server:assembleDebug" + - name: "Upload debug APK" + uses: actions/upload-artifact@v4 + with: + name: RemoteDroidGuard-Server-debug.apk + path: remote-droidguard-server/build/outputs/apk/debug/*.apk + if-no-files-found: error diff --git a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/RemoteHandleImpl.kt b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/RemoteHandleImpl.kt index 4e86e4c47e..a37db1125e 100644 --- a/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/RemoteHandleImpl.kt +++ b/play-services-droidguard/core/src/main/kotlin/org/microg/gms/droidguard/core/RemoteHandleImpl.kt @@ -20,16 +20,54 @@ private const val TAG = "RemoteGuardImpl" class RemoteHandleImpl(private val context: Context, private val packageName: String) : IDroidGuardHandle.Stub() { private var flow: String? = null private var request: DroidGuardResultsRequest? = null + private var sessionId: String? = null private val url: String get() = DroidGuardPreferences.getNetworkServerUrl(context) ?: throw IllegalStateException("Network URL required") override fun init(flow: String?) { Log.d(TAG, "init($flow)") this.flow = flow + // Try to initialize multi-step session + try { + sessionId = callInitEndpoint(flow, null) + Log.d(TAG, "Multi-step session initialized: $sessionId") + } catch (e: Exception) { + Log.w(TAG, "Failed to initialize multi-step session, will fallback to single-shot", e) + sessionId = null + } } override fun snapshot(map: Map?): ByteArray { Log.d(TAG, "snapshot($map)") + + // Multi-step mode: use /v2/snapshot with sessionId + val currentSessionId = sessionId + if (currentSessionId != null) { + return snapshotMultiStep(currentSessionId, map) + } + + // Single-shot mode: use / endpoint (backwards compatibility) + return snapshotSingleShot(map) + } + + private fun snapshotMultiStep(sessionId: String, map: Map?): ByteArray { + Log.d(TAG, "Using multi-step snapshot with sessionId: $sessionId") + val endpoint = "$url/v2/snapshot?sessionId=${Uri.encode(sessionId)}" + val payload = map.orEmpty().map { Uri.encode(it.key as String) + "=" + Uri.encode(it.value as String) }.joinToString("&") + + val connection = URL(endpoint).openConnection() as HttpURLConnection + Log.d(TAG, "POST $endpoint: $payload") + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") + connection.requestMethod = "POST" + connection.doInput = true + connection.doOutput = true + connection.outputStream.use { it.write(payload.encodeToByteArray()) } + val bytes = connection.inputStream.use { it.readBytes() }.decodeToString() + return Base64.decode(bytes, Base64.URL_SAFE + Base64.NO_WRAP + Base64.NO_PADDING) + } + + private fun snapshotSingleShot(map: Map?): ByteArray { + Log.d(TAG, "Using single-shot snapshot (backwards compatibility)") val paramsMap = mutableMapOf("flow" to flow, "source" to packageName) for (key in request?.bundle?.keySet().orEmpty()) { request?.bundle?.getString(key)?.let { paramsMap["x-request-$key"] = it } @@ -49,14 +87,76 @@ class RemoteHandleImpl(private val context: Context, private val packageName: St override fun close() { Log.d(TAG, "close()") + + // Close multi-step session on server if one exists + val currentSessionId = sessionId + if (currentSessionId != null) { + try { + closeSession(currentSessionId) + } catch (e: Exception) { + Log.w(TAG, "Failed to close session on server", e) + } + } + + // Clean up local state + this.sessionId = null this.request = null this.flow = null } + private fun closeSession(sessionId: String) { + Log.d(TAG, "Closing session on server: $sessionId") + val endpoint = "$url/v2/close?sessionId=${Uri.encode(sessionId)}" + val connection = URL(endpoint).openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.doInput = true + connection.doOutput = true + connection.outputStream.use { it.write(ByteArray(0)) } + val responseCode = connection.responseCode + Log.d(TAG, "Close session response: $responseCode") + connection.disconnect() + } + override fun initWithRequest(flow: String?, request: DroidGuardResultsRequest?): DroidGuardInitReply? { Log.d(TAG, "initWithRequest($flow, $request)") this.flow = flow this.request = request + // Try to initialize multi-step session + try { + sessionId = callInitEndpoint(flow, request) + Log.d(TAG, "Multi-step session initialized: $sessionId") + } catch (e: Exception) { + Log.w(TAG, "Failed to initialize multi-step session, will fallback to single-shot", e) + sessionId = null + } return null } + + private fun callInitEndpoint(flow: String?, request: DroidGuardResultsRequest?): String? { + val paramsMap = mutableMapOf("flow" to flow, "source" to packageName) + for (key in request?.bundle?.keySet().orEmpty()) { + request?.bundle?.getString(key)?.let { paramsMap["x-request-$key"] = it } + } + val params = paramsMap.map { Uri.encode(it.key) + "=" + Uri.encode(it.value) }.joinToString("&") + val endpoint = "$url/v2/init?$params" + + Log.d(TAG, "POST $endpoint") + val connection = URL(endpoint).openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.doInput = true + connection.doOutput = true + connection.outputStream.use { it.write(ByteArray(0)) } + + val responseCode = connection.responseCode + if (responseCode != 200) { + throw RuntimeException("Init endpoint returned $responseCode") + } + + val sessionId = connection.inputStream.use { it.readBytes() }.decodeToString().trim() + if (sessionId.isEmpty()) { + throw RuntimeException("Init endpoint returned empty sessionId") + } + + return sessionId + } } \ No newline at end of file diff --git a/remote-droidguard-server/build.gradle b/remote-droidguard-server/build.gradle new file mode 100644 index 0000000000..b1fa9a7b41 --- /dev/null +++ b/remote-droidguard-server/build.gradle @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +android { + namespace "org.microg.gms.droidguard.server" + compileSdkVersion androidCompileSdk + buildToolsVersion "$androidBuildVersionTools" + + defaultConfig { + applicationId "org.microg.gms.droidguard.remote_server" + versionName "1.0.0" + versionCode 1 + minSdkVersion 21 + targetSdkVersion androidTargetSdk + + multiDexEnabled true + } + + buildTypes { + debug { + minifyEnabled false + } + release { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + buildFeatures { + buildConfig = true + } + + lintOptions { + disable 'MissingTranslation' + } +} + +dependencies { + implementation project(':play-services-droidguard') + implementation project(':play-services-tasks-ktx') + + implementation "androidx.core:core-ktx:$coreVersion" + implementation "androidx.appcompat:appcompat:$appcompatVersion" + implementation "com.google.android.material:material:$materialVersion" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion" + implementation "androidx.multidex:multidex:$multidexVersion" +} + +if (file('user.gradle').exists()) { + apply from: 'user.gradle' +} diff --git a/remote-droidguard-server/src/main/AndroidManifest.xml b/remote-droidguard-server/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..20dfb6034a --- /dev/null +++ b/remote-droidguard-server/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/BootReceiver.kt b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/BootReceiver.kt new file mode 100644 index 0000000000..c125e76e4b --- /dev/null +++ b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/BootReceiver.kt @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.droidguard.server + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log + +/** + * Starts [DroidGuardServerService] automatically when the device boots, + * if the user has enabled "Start Server on Boot" in SharedPreferences. + */ +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED && + intent.action != "android.intent.action.QUICKBOOT_POWERON" + ) return + + // Check if boot start is enabled in SharedPreferences + val prefs = context.getSharedPreferences("droidguard_server_prefs", Context.MODE_PRIVATE) + val bootStartEnabled = prefs.getBoolean(MainActivity.PREF_BOOT_START, true) + + if (!bootStartEnabled) { + Log.i(TAG, "Boot start is disabled — not starting DroidGuardServerService") + return + } + + Log.i(TAG, "Boot completed — starting DroidGuardServerService") + val serviceIntent = Intent(context, DroidGuardServerService::class.java) + .setAction(DroidGuardServerService.ACTION_START) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + } + + companion object { + private const val TAG = "DroidGuardBootReceiver" + } +} diff --git a/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/DroidGuardServerService.kt b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/DroidGuardServerService.kt new file mode 100644 index 0000000000..f51087cb28 --- /dev/null +++ b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/DroidGuardServerService.kt @@ -0,0 +1,378 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.droidguard.server + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import com.google.android.gms.droidguard.DroidGuard +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import com.google.android.gms.tasks.await +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.util.concurrent.ConcurrentHashMap + +/** + * Foreground service that exposes a local HTTP server for multi-step DroidGuard evaluation. + * + * Supported endpoints: + * POST /?flow=…&source=… (single-shot, compatible with RemoteHandleImpl) + * GET /status returns "OK" + */ +class DroidGuardServerService : Service() { + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var serverSocket: ServerSocket? = null + private var serverJob: Job? = null + private var currentIp: String? = null + + // Active multi-step sessions: sessionId -> open DroidGuardHandle + private val sessions = ConcurrentHashMap() + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_START -> startServer() + ACTION_STOP -> stopServer() + } + return START_NOT_STICKY + } + + override fun onDestroy() { + stopServer() + scope.cancel() + super.onDestroy() + } + + // ---- Server lifecycle -------------------------------------------------- + + private fun startServer() { + if (serverJob?.isActive == true) return + + val ip = MainActivity.getTailscaleIp() ?: run { + Log.w(TAG, "No Tailscale IP found; binding to all interfaces") + null + } + currentIp = ip + + startForeground(NOTIFICATION_ID, buildNotification(STATUS_LISTENING, ip, DEFAULT_PORT)) + isRunning = true + + serverJob = scope.launch { + try { + val ss = ServerSocket(DEFAULT_PORT) + serverSocket = ss + broadcastStatus(STATUS_LISTENING, ip, DEFAULT_PORT) + Log.i(TAG, "Listening on ${ip ?: "0.0.0.0"}:$DEFAULT_PORT") + + while (!ss.isClosed) { + val client: Socket = try { + ss.accept() + } catch (e: SocketException) { + break + } + launch { handleClient(client) } + } + } catch (e: Exception) { + Log.e(TAG, "Server error", e) + } finally { + broadcastStatus(STATUS_STOPPED, null, DEFAULT_PORT) + isRunning = false + stopSelf() + } + } + } + + private fun stopServer() { + serverJob?.cancel() + serverJob = null + try { + serverSocket?.close() + } catch (ignored: Exception) { + } + serverSocket = null + closeAllSessions() + isRunning = false + broadcastStatus(STATUS_STOPPED, null, DEFAULT_PORT) + @Suppress("DEPRECATION") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + stopForeground(true) + } + stopSelf() + } + + private fun closeAllSessions() { + sessions.forEach { (_, handle) -> + try { handle.close() } catch (ignored: Exception) { } + } + sessions.clear() + } + + // ---- HTTP request handling -------------------------------------------- + + private suspend fun handleClient(socket: Socket) { + socket.use { s -> + try { + val reader = BufferedReader(InputStreamReader(s.getInputStream())) + val writer = PrintWriter(s.getOutputStream(), true) + + // Parse request line: METHOD PATH HTTP/x.x + val requestLine = reader.readLine() ?: return + Log.d(TAG, "Request: $requestLine") + val parts = requestLine.trim().split(" ") + if (parts.size < 2) { + sendResponse(writer, 400, "Bad Request", "text/plain", "Bad request line") + return + } + val method = parts[0].uppercase() + val rawPath = parts[1] + + // Read headers (consume until blank line) + val headers = mutableMapOf() + var line = reader.readLine() + while (line != null && line.isNotEmpty()) { + val colon = line.indexOf(':') + if (colon > 0) { + headers[line.substring(0, colon).trim().lowercase()] = + line.substring(colon + 1).trim() + } + line = reader.readLine() + } + + // Read body if present + val contentLength = headers["content-length"]?.toIntOrNull() ?: 0 + val bodyBuilder = StringBuilder() + if (contentLength > 0) { + val buf = CharArray(contentLength) + var remaining = contentLength + while (remaining > 0) { + val read = reader.read(buf, contentLength - remaining, remaining) + if (read < 0) break + remaining -= read + } + bodyBuilder.append(buf) + } + val body = bodyBuilder.toString() + + broadcastStatus(STATUS_PROCESSING, currentIp, DEFAULT_PORT) + updateNotification(STATUS_PROCESSING, currentIp, DEFAULT_PORT) + + try { + dispatch(method, rawPath, body, writer) + } finally { + broadcastStatus(STATUS_LISTENING, currentIp, DEFAULT_PORT) + updateNotification(STATUS_LISTENING, currentIp, DEFAULT_PORT) + } + } catch (e: Exception) { + Log.e(TAG, "Error handling client", e) + } + } + } + + private suspend fun dispatch(method: String, rawPath: String, body: String, writer: PrintWriter) { + val qIdx = rawPath.indexOf('?') + val path = if (qIdx >= 0) rawPath.substring(0, qIdx) else rawPath + val queryString = if (qIdx >= 0) rawPath.substring(qIdx + 1) else "" + val queryParams = parseParams(queryString) + + when { + method == "GET" && path == "/status" -> { + sendResponse(writer, 200, "OK", "text/plain", "OK") + } + + // Single-shot guard (compatible with RemoteHandleImpl) + method == "POST" && (path == "/" || path == "/guard") -> { + val flow = queryParams["flow"] ?: run { + sendResponse(writer, 400, "Bad Request", "text/plain", "Missing 'flow' parameter") + return + } + val source = queryParams["source"] ?: packageName + val dataMap = parseParams(body) + + try { + val result = DroidGuard.getClient(this, source) + .getResults(flow, dataMap, null) + .await() + sendResponse(writer, 200, "OK", "text/plain", result) + } catch (e: Exception) { + Log.e(TAG, "DroidGuard guard failed", e) + sendResponse(writer, 500, "Internal Server Error", "text/plain", "DroidGuard error: ${e.message}") + } + } + + // Multi-step: init session + method == "POST" && path == "/v2/init" -> { + val flow = queryParams["flow"] ?: run { + sendResponse(writer, 400, "Bad Request", "text/plain", "Missing 'flow' parameter") + return + } + val source = queryParams["source"] ?: packageName + try { + val handle = DroidGuard.getClient(this, source).init(flow, null).await() + val sessionId = java.util.UUID.randomUUID().toString() + sessions[sessionId] = handle + sendResponse(writer, 200, "OK", "text/plain", sessionId) + } catch (e: Exception) { + Log.e(TAG, "DroidGuard init failed", e) + sendResponse(writer, 500, "Internal Server Error", "text/plain", "DroidGuard init error: ${e.message}") + } + } + + // Multi-step: snapshot + method == "POST" && path == "/v2/snapshot" -> { + val sessionId = queryParams["sessionId"] ?: run { + sendResponse(writer, 400, "Bad Request", "text/plain", "Missing 'sessionId'") + return + } + val handle = sessions[sessionId] ?: run { + sendResponse(writer, 404, "Not Found", "text/plain", "Session not found") + return + } + val dataMap = parseParams(body) + try { + val result = handle.snapshot(dataMap) + sendResponse(writer, 200, "OK", "text/plain", result ?: "") + } catch (e: Exception) { + Log.e(TAG, "DroidGuard snapshot failed", e) + sendResponse(writer, 500, "Internal Server Error", "text/plain", "Snapshot error: ${e.message}") + } + } + + // Multi-step: close session + method == "POST" && path == "/v2/close" -> { + val sessionId = queryParams["sessionId"] ?: run { + sendResponse(writer, 400, "Bad Request", "text/plain", "Missing 'sessionId'") + return + } + sessions.remove(sessionId)?.close() + sendResponse(writer, 200, "OK", "text/plain", "closed") + } + + else -> { + sendResponse(writer, 404, "Not Found", "text/plain", "Not found: $path") + } + } + } + + // ---- Helpers ----------------------------------------------------------- + + private fun parseParams(encoded: String): Map { + if (encoded.isBlank()) return emptyMap() + return encoded.split("&").mapNotNull { pair -> + val eq = pair.indexOf('=') + if (eq < 0) null + else Uri.decode(pair.substring(0, eq)) to Uri.decode(pair.substring(eq + 1)) + }.toMap() + } + + private fun sendResponse(writer: PrintWriter, code: Int, reason: String, contentType: String, body: String) { + val bytes = body.encodeToByteArray() + writer.print("HTTP/1.1 $code $reason\r\n") + writer.print("Content-Type: $contentType; charset=UTF-8\r\n") + writer.print("Content-Length: ${bytes.size}\r\n") + writer.print("Connection: close\r\n") + writer.print("\r\n") + writer.print(body) + writer.flush() + } + + // ---- Notifications & broadcasts ---------------------------------------- + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = getString(R.string.notification_channel_description) + } + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + + private fun buildNotification(status: String, ip: String?, port: Int): android.app.Notification { + val activityIntent = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val text = when (status) { + STATUS_LISTENING -> getString(R.string.notification_text_listening, ip ?: "0.0.0.0", port) + STATUS_PROCESSING -> getString(R.string.notification_text_processing) + else -> getString(R.string.notification_text_idle) + } + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(text) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentIntent(activityIntent) + .setOngoing(true) + .build() + } + + private fun updateNotification(status: String, ip: String?, port: Int) { + @Suppress("DEPRECATION") + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + nm.notify(NOTIFICATION_ID, buildNotification(status, ip, port)) + } + + private fun broadcastStatus(status: String, ip: String?, port: Int) { + sendBroadcast(Intent(ACTION_STATUS_UPDATE).apply { + putExtra(EXTRA_STATUS, status) + ip?.let { putExtra(EXTRA_IP, it) } + putExtra(EXTRA_PORT, port) + setPackage(packageName) + }) + } + + companion object { + private const val TAG = "DroidGuardServerSvc" + private const val CHANNEL_ID = "droidguard_server" + private const val NOTIFICATION_ID = 7070 + + const val DEFAULT_PORT = 7070 + + const val ACTION_START = "org.microg.gms.droidguard.server.START" + const val ACTION_STOP = "org.microg.gms.droidguard.server.STOP" + const val ACTION_STATUS_UPDATE = "org.microg.gms.droidguard.server.STATUS_UPDATE" + const val EXTRA_STATUS = "status" + const val EXTRA_IP = "ip" + const val EXTRA_PORT = "port" + + const val STATUS_LISTENING = "listening" + const val STATUS_PROCESSING = "processing" + const val STATUS_STOPPED = "stopped" + + @Volatile + var isRunning: Boolean = false + private set + } +} diff --git a/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/MainActivity.kt b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/MainActivity.kt new file mode 100644 index 0000000000..6835bbe609 --- /dev/null +++ b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/MainActivity.kt @@ -0,0 +1,405 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.droidguard.server + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.content.res.ColorStateList +import android.graphics.Color +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.util.Log +import android.view.View +import android.view.animation.AnimationUtils +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import com.google.android.material.button.MaterialButton +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.switchmaterial.SwitchMaterial +import java.net.Inet4Address +import java.net.NetworkInterface + +class MainActivity : AppCompatActivity() { + + private lateinit var tvTailscaleIp: TextView + private lateinit var btnCopyIp: MaterialButton + private lateinit var tvStatus: TextView + private lateinit var tvWarning: TextView + private lateinit var btnToggle: MaterialButton + private lateinit var btnTailscaleAction: MaterialButton + private lateinit var switchBootStart: SwitchMaterial + private lateinit var statusIndicator: View + + private var serverRunning = false + private var currentIp: String? = null + + private lateinit var prefs: SharedPreferences + + private val statusReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + DroidGuardServerService.ACTION_STATUS_UPDATE -> { + val status = intent.getStringExtra(DroidGuardServerService.EXTRA_STATUS) ?: return + val ip = intent.getStringExtra(DroidGuardServerService.EXTRA_IP) + val port = intent.getIntExtra(DroidGuardServerService.EXTRA_PORT, DroidGuardServerService.DEFAULT_PORT) + updateStatus(status, ip, port) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + tvTailscaleIp = findViewById(R.id.tv_tailscale_ip) + btnCopyIp = findViewById(R.id.btn_copy_ip) + tvStatus = findViewById(R.id.tv_status) + tvWarning = findViewById(R.id.tv_warning) + btnToggle = findViewById(R.id.btn_toggle_server) + btnTailscaleAction = findViewById(R.id.btn_tailscale_action) + switchBootStart = findViewById(R.id.switch_boot_start) + statusIndicator = findViewById(R.id.status_indicator) + + // Initialize boot start toggle from SharedPreferences (default: true) + switchBootStart.isChecked = prefs.getBoolean(PREF_BOOT_START, true) + switchBootStart.setOnCheckedChangeListener { _, isChecked -> + prefs.edit().putBoolean(PREF_BOOT_START, isChecked).apply() + Log.i(TAG, "Boot start preference changed to: $isChecked") + } + + btnToggle.setOnClickListener { + if (!serverRunning) { + requestPermissionsAndStart() + } else { + stopServer() + } + } + + btnCopyIp.setOnClickListener { + copyIpToClipboard() + } + + btnTailscaleAction.setOnClickListener { + openTailscaleApp() + } + + updateButtonState(false) + checkTailscaleAndUpdateUI() + + // Check battery optimization on first launch + checkBatteryOptimizationOnFirstLaunch() + } + + override fun onResume() { + super.onResume() + val filter = IntentFilter(DroidGuardServerService.ACTION_STATUS_UPDATE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(statusReceiver, filter, RECEIVER_NOT_EXPORTED) + } else { + @Suppress("UnspecifiedRegisterReceiverFlag") + registerReceiver(statusReceiver, filter) + } + val running = DroidGuardServerService.isRunning + if (running != serverRunning) { + updateButtonState(running) + } + if (!running) { + tvStatus.setText(R.string.status_idle) + tvWarning.visibility = View.GONE + setIndicatorActive(false) + } + checkTailscaleAndUpdateUI() + } + + override fun onPause() { + super.onPause() + try { + unregisterReceiver(statusReceiver) + } catch (ignored: IllegalArgumentException) { + } + } + + // ---- Tailscale check & UI update -------------------------------------- + + private fun checkBatteryOptimizationOnFirstLaunch() { + // Only show dialog on first launch + val isFirstLaunch = prefs.getBoolean(PREF_FIRST_LAUNCH, true) + if (!isFirstLaunch) return + + // Mark as no longer first launch + prefs.edit().putBoolean(PREF_FIRST_LAUNCH, false).apply() + + // Check if battery optimization is already disabled + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + @Suppress("DEPRECATION") + val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + showBatteryOptimizationDialog() + } + } + } + + private fun showBatteryOptimizationDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_battery_title) + .setMessage(R.string.dialog_battery_message) + .setPositiveButton(R.string.dialog_battery_settings) { _, _ -> + try { + startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + ) + } catch (e: Exception) { + Log.w(TAG, "Cannot open battery optimization settings", e) + Toast.makeText(this, R.string.cannot_open_settings, Toast.LENGTH_SHORT).show() + } + } + .setNegativeButton(R.string.dialog_battery_later, null) + .setCancelable(false) + .show() + } + + private fun checkTailscaleAndUpdateUI() { + val isTailscaleInstalled = isTailscaleInstalled() + val tailscaleIp = getTailscaleIp() + + currentIp = tailscaleIp + + if (!isTailscaleInstalled) { + // Tailscale not installed + tvTailscaleIp.text = getString(R.string.tailscale_not_installed) + btnTailscaleAction.text = getString(R.string.btn_install_tailscale) + btnTailscaleAction.visibility = View.VISIBLE + btnCopyIp.isEnabled = false + } else if (tailscaleIp == null) { + // Tailscale installed but not connected + tvTailscaleIp.text = getString(R.string.tailscale_not_active) + btnTailscaleAction.text = getString(R.string.btn_open_tailscale) + btnTailscaleAction.visibility = View.VISIBLE + btnCopyIp.isEnabled = false + } else { + // Tailscale connected with IP + tvTailscaleIp.text = getString(R.string.tailscale_ip_format, tailscaleIp, DroidGuardServerService.DEFAULT_PORT) + btnTailscaleAction.visibility = View.GONE + btnCopyIp.isEnabled = true + } + } + + private fun isTailscaleInstalled(): Boolean { + return try { + packageManager.getPackageInfo("com.tailscale.ipn", 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + } + + private fun openTailscaleApp() { + if (!isTailscaleInstalled()) { + // Open Play Store to install + val playStoreIntent = try { + Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=com.tailscale.ipn")) + } catch (e: Exception) { + Intent(Intent.ACTION_VIEW, Uri.parse("https://play.google.com/store/apps/details?id=com.tailscale.ipn")) + } + startActivity(playStoreIntent) + } else { + // Open Tailscale app + val launchIntent = packageManager.getLaunchIntentForPackage("com.tailscale.ipn") + if (launchIntent != null) { + startActivity(launchIntent) + } else { + Toast.makeText(this, R.string.cannot_open_tailscale, Toast.LENGTH_SHORT).show() + } + } + } + + private fun copyIpToClipboard() { + val ip = currentIp + if (ip != null) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText( + getString(R.string.clipboard_label), + getString(R.string.server_address_format, ip, DroidGuardServerService.DEFAULT_PORT) + ) + clipboard.setPrimaryClip(clip) + Toast.makeText(this, R.string.ip_copied, Toast.LENGTH_SHORT).show() + } + } + + // ---- Permissions & server start ---------------------------------------- + + private fun requestPermissionsAndStart() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIFICATION) + return + } + } + + startServer() + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQ_NOTIFICATION) { + startServer() + } + } + + private fun startServer() { + val intent = Intent(this, DroidGuardServerService::class.java) + .setAction(DroidGuardServerService.ACTION_START) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + tvStatus.setText(R.string.status_idle) + } + + private fun stopServer() { + val intent = Intent(this, DroidGuardServerService::class.java) + .setAction(DroidGuardServerService.ACTION_STOP) + startService(intent) + tvStatus.setText(R.string.status_idle) + tvWarning.visibility = View.GONE + updateButtonState(false) + setIndicatorActive(false) + } + + // ---- Status updates ---------------------------------------------------- + + private fun updateStatus(status: String, ip: String?, port: Int) { + when (status) { + DroidGuardServerService.STATUS_LISTENING -> { + if (ip != null) { + tvStatus.text = getString(R.string.status_listening, ip, port) + currentIp = ip + tvTailscaleIp.text = getString(R.string.tailscale_ip_format, ip, port) + btnCopyIp.isEnabled = true + tvWarning.visibility = View.GONE + } else { + tvStatus.setText(R.string.status_listening_no_ip) + currentIp = null + tvTailscaleIp.text = getString(R.string.tailscale_not_active) + btnCopyIp.isEnabled = false + tvWarning.text = getString(R.string.warning_no_tailscale_ip) + tvWarning.visibility = View.VISIBLE + } + updateButtonState(true) + setIndicatorActive(true) + } + DroidGuardServerService.STATUS_PROCESSING -> { + tvStatus.setText(R.string.status_processing) + } + DroidGuardServerService.STATUS_STOPPED -> { + tvStatus.setText(R.string.status_idle) + tvWarning.visibility = View.GONE + updateButtonState(false) + setIndicatorActive(false) + checkTailscaleAndUpdateUI() + } + } + } + + // ---- UI helpers -------------------------------------------------------- + + private fun updateButtonState(running: Boolean) { + serverRunning = running + if (running) { + btnToggle.text = getString(R.string.btn_stop_server) + btnToggle.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#D32F2F")) + } else { + btnToggle.text = getString(R.string.btn_start_server) + btnToggle.backgroundTintList = ColorStateList.valueOf(Color.parseColor("#388E3C")) + } + } + + private fun setIndicatorActive(active: Boolean) { + val color = if (active) Color.parseColor("#4CAF50") else Color.parseColor("#9E9E9E") + ViewCompat.setBackgroundTintList(statusIndicator, ColorStateList.valueOf(color)) + if (active) { + val anim = AnimationUtils.loadAnimation(this, R.anim.pulse) + statusIndicator.startAnimation(anim) + } else { + statusIndicator.clearAnimation() + statusIndicator.alpha = 1f + } + } + + companion object { + private const val TAG = "DroidGuardServerMain" + private const val REQ_NOTIFICATION = 1001 + + private const val PREFS_NAME = "droidguard_server_prefs" + private const val PREF_FIRST_LAUNCH = "first_launch" + const val PREF_BOOT_START = "boot_start" + + /** + * Returns the device's Tailscale IP by enumerating all network interfaces + * and checking for addresses in the Tailscale CGNAT range (100.64.0.0/10). + * Returns null if Tailscale is not running or no IPv4 address is found. + */ + fun getTailscaleIp(): String? { + return try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + if (!iface.isUp) continue + + val addresses = iface.inetAddresses.toList() + val tailscaleIp = addresses + .filterIsInstance() + .firstOrNull { addr -> + !addr.isLoopbackAddress && isTailscaleIp(addr) + } + + if (tailscaleIp != null) { + Log.i(TAG, "Found Tailscale IP: ${tailscaleIp.hostAddress} on interface ${iface.name}") + return tailscaleIp.hostAddress + } + } + Log.w(TAG, "No Tailscale IP found in any network interface") + null + } catch (e: Exception) { + Log.w(TAG, "Could not enumerate network interfaces", e) + null + } + } + + /** + * Checks if an IPv4 address is in the Tailscale CGNAT range (100.64.0.0/10). + * This range is 100.64.0.0 - 100.127.255.255. + */ + private fun isTailscaleIp(addr: Inet4Address): Boolean { + val bytes = addr.address + val firstOctet = bytes[0].toInt() and 0xFF + val secondOctet = bytes[1].toInt() and 0xFF + + return firstOctet == 100 && secondOctet in 64..127 + } + } +} diff --git a/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/RemoteDroidGuardApp.kt b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/RemoteDroidGuardApp.kt new file mode 100644 index 0000000000..c8dd87ab2c --- /dev/null +++ b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/RemoteDroidGuardApp.kt @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: 2025 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.droidguard.server + +import androidx.multidex.MultiDexApplication + +class RemoteDroidGuardApp : MultiDexApplication() diff --git a/remote-droidguard-server/src/main/res/anim/pulse.xml b/remote-droidguard-server/src/main/res/anim/pulse.xml new file mode 100644 index 0000000000..73288f9708 --- /dev/null +++ b/remote-droidguard-server/src/main/res/anim/pulse.xml @@ -0,0 +1,12 @@ + + + diff --git a/remote-droidguard-server/src/main/res/drawable/bg_status_indicator.xml b/remote-droidguard-server/src/main/res/drawable/bg_status_indicator.xml new file mode 100644 index 0000000000..9930826542 --- /dev/null +++ b/remote-droidguard-server/src/main/res/drawable/bg_status_indicator.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/remote-droidguard-server/src/main/res/drawable/ic_launcher_dg.xml b/remote-droidguard-server/src/main/res/drawable/ic_launcher_dg.xml new file mode 100644 index 0000000000..6b40b3379f --- /dev/null +++ b/remote-droidguard-server/src/main/res/drawable/ic_launcher_dg.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/remote-droidguard-server/src/main/res/layout/activity_main.xml b/remote-droidguard-server/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..2a8a2aef84 --- /dev/null +++ b/remote-droidguard-server/src/main/res/layout/activity_main.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/remote-droidguard-server/src/main/res/values/strings.xml b/remote-droidguard-server/src/main/res/values/strings.xml new file mode 100644 index 0000000000..6605aba9d0 --- /dev/null +++ b/remote-droidguard-server/src/main/res/values/strings.xml @@ -0,0 +1,47 @@ + + + + DG Server + DroidGuard Server Status + Status: + Idle + Listening on %1$s:%2$d + Listening (no Tailscale IP) + Processing + Start Server + Stop Server + Warning: No Tailscale IP detected. Local connections only. + DroidGuard Server + Foreground notification for the Remote DroidGuard Server + Remote DroidGuard Server + Server is stopped + Listening on %1$s:%2$d + Processing a DroidGuard challenge\u2026 + microg-dg://%1$s:%2$d + + + TAILSCALE IP + %1$s:%2$d + Tailscale not installed + Searching for Tailscale\u2026 (Ensure Tailscale app is Active) + Install Tailscale from Play Store + Open Tailscale App + Copy IP to Clipboard + DroidGuard Server Address + IP address copied to clipboard + Cannot open Tailscale app + + + Battery Optimization Required + To keep the server alive 24/7 and allow auto-start on boot, please set Battery to \'Unrestricted\' in the next screen. + Open Settings + Later + Cannot open settings + + + Start Server on Boot + Automatically start the server when device boots + diff --git a/settings.gradle b/settings.gradle index 1ab99b9000..de1f072c9d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,6 +19,7 @@ def hasModule = (String name, boolean enabledByDefault) -> { include ':fake-signature' include ':safe-parcel-processor' include ':vending-app' +include ':remote-droidguard-server' include ':play-services-ads' include ':play-services-ads-base'