From b4cdada9bd0e734e7919535d12bcbf5995aba643 Mon Sep 17 00:00:00 2001 From: Akinniranye Samuel Tomiwa Date: Tue, 24 Mar 2026 01:37:40 +0100 Subject: [PATCH 1/8] Enhance Gradle build workflow with input parameters Added input parameter for branch/tag to build workflow and updated tasks for Gradle assembly and linting. --- .github/workflows/build.yml | 65 ++++++++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ae962d846..9c610c4618 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,9 +1,14 @@ name: "Gradle build" permissions: {} on: - - push - - pull_request - - workflow_dispatch + push: + pull_request: + workflow_dispatch: + inputs: + ref: + description: "Branch, tag, or commit SHA to build (defaults to the current branch)" + required: false + default: "" jobs: build: @@ -15,6 +20,21 @@ jobs: strategy: matrix: target: [Debug, Release] + include: + # Debug: build all variants so every flavor is compile-checked. + - target: Debug + assembleTask: assembleDebug + lintTask: lintDebug + # Release: build only the primary open-source variant (vtm maps + + # default Android target) to produce a ~100 MB artifact instead of + # packaging all 15 flavor combinations (~1 GB). + - target: Release + assembleTask: >- + :play-services-core:assembleVtmDefaultRelease + :vending-app:assembleDefaultRelease + lintTask: >- + :play-services-core:lintVtmDefaultRelease + :vending-app:lintDefaultRelease steps: - name: "Free disk space" @@ -29,6 +49,7 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + ref: ${{ inputs.ref || github.ref }} - name: "Setup Java" uses: actions/setup-java@v5 with: @@ -59,6 +80,40 @@ jobs: fi done - name: "Execute Gradle assemble" - run: "./gradlew assemble${{ matrix.target }}" + run: "./gradlew ${{ matrix.assembleTask }}" - name: "Execute Gradle lint" - run: "./gradlew lint${{ matrix.target }}" + run: "./gradlew ${{ matrix.lintTask }}" + - name: "Verify APK sizes" + run: | + # Find all output APKs (exclude intermediates) and verify none exceed 1 GB + limit_bytes=$((1024 * 1024 * 1024)) + found=0 + oversized=0 + while IFS= read -r apk; do + size=$(wc -c < "$apk") + size_mb=$(( size / 1024 / 1024 )) + echo "APK: $apk size: ${size_mb} MB" + found=$(( found + 1 )) + if [ "$size" -gt "$limit_bytes" ]; then + echo "::error file=$apk::APK exceeds 1 GB limit (${size_mb} MB)" + oversized=$(( oversized + 1 )) + fi + done < <(find . -name '*.apk' -not -path '*/intermediates/*') + echo "Found $found APK(s), $oversized oversized." + if [ "$oversized" -gt 0 ]; then + exit 1 + fi + - name: "Stage release APKs" + if: matrix.target == 'Release' + run: | + mkdir -p /tmp/release-apks + find play-services-core/build/outputs/apk/vtmDefault/release \ + vending-app/build/outputs/apk/default/release \ + -name '*.apk' -exec cp {} /tmp/release-apks/ \; + - name: "Upload APK artifacts" + if: matrix.target == 'Release' + uses: actions/upload-artifact@v4 + with: + name: apk-release + path: /tmp/release-apks/ + if-no-files-found: error From b24390a12c5ccc23d242657eeaabfbc01c25bdfa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:15:35 +0000 Subject: [PATCH 2/8] Add Remote DroidGuard Server module and update CI/CD workflow Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> Agent-Logs-Url: https://github.com/samuel-asleep/GmsCore/sessions/3c65d0bb-8d6d-4cb8-a190-3ffd08b3952c --- .github/workflows/build.yml | 15 + remote-droidguard-server/build.gradle | 68 ++++ .../src/main/AndroidManifest.xml | 48 +++ .../gms/droidguard/server/BootReceiver.kt | 37 ++ .../server/DroidGuardServerService.kt | 378 ++++++++++++++++++ .../gms/droidguard/server/MainActivity.kt | 241 +++++++++++ .../droidguard/server/RemoteDroidGuardApp.kt | 10 + .../src/main/res/layout/activity_main.xml | 69 ++++ .../src/main/res/values/strings.xml | 22 + settings.gradle | 1 + 10 files changed, 889 insertions(+) create mode 100644 remote-droidguard-server/build.gradle create mode 100644 remote-droidguard-server/src/main/AndroidManifest.xml create mode 100644 remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/BootReceiver.kt create mode 100644 remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/DroidGuardServerService.kt create mode 100644 remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/MainActivity.kt create mode 100644 remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/RemoteDroidGuardApp.kt create mode 100644 remote-droidguard-server/src/main/res/layout/activity_main.xml create mode 100644 remote-droidguard-server/src/main/res/values/strings.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9c610c4618..869d03817e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,7 @@ name: "Gradle build" permissions: {} on: push: + branches: [main, master] pull_request: workflow_dispatch: inputs: @@ -32,9 +33,11 @@ jobs: assembleTask: >- :play-services-core:assembleVtmDefaultRelease :vending-app:assembleDefaultRelease + :remote-droidguard-server:assembleRelease lintTask: >- :play-services-core:lintVtmDefaultRelease :vending-app:lintDefaultRelease + :remote-droidguard-server:lintRelease steps: - name: "Free disk space" @@ -110,6 +113,11 @@ jobs: find play-services-core/build/outputs/apk/vtmDefault/release \ vending-app/build/outputs/apk/default/release \ -name '*.apk' -exec cp {} /tmp/release-apks/ \; + # Copy and rename the Remote DroidGuard Server APK + rdg_apk=$(find remote-droidguard-server/build/outputs/apk/release -name '*.apk' | head -1) + if [ -n "$rdg_apk" ]; then + cp "$rdg_apk" /tmp/release-apks/RemoteDroidGuard-Server.apk + fi - name: "Upload APK artifacts" if: matrix.target == 'Release' uses: actions/upload-artifact@v4 @@ -117,3 +125,10 @@ jobs: name: apk-release path: /tmp/release-apks/ if-no-files-found: error + - name: "Upload RemoteDroidGuard-Server.apk artifact" + if: matrix.target == 'Release' + uses: actions/upload-artifact@v4 + with: + name: RemoteDroidGuard-Server.apk + path: /tmp/release-apks/RemoteDroidGuard-Server.apk + if-no-files-found: warn diff --git a/remote-droidguard-server/build.gradle b/remote-droidguard-server/build.gradle new file mode 100644 index 0000000000..06d3c91c43 --- /dev/null +++ b/remote-droidguard-server/build.gradle @@ -0,0 +1,68 @@ +/* + * 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.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 "com.google.zxing:core:3.5.3" + + 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..f4a8fcfa0d --- /dev/null +++ b/remote-droidguard-server/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..5d2ac9ad5c --- /dev/null +++ b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/BootReceiver.kt @@ -0,0 +1,37 @@ +/* + * 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 it was running before the reboot. + */ +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 + + 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..8e1b3ce82d --- /dev/null +++ b/remote-droidguard-server/src/main/kotlin/org/microg/gms/droidguard/server/MainActivity.kt @@ -0,0 +1,241 @@ +/* + * 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.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.graphics.Bitmap +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.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import android.widget.ToggleButton +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import java.net.InetAddress +import java.net.NetworkInterface + +class MainActivity : AppCompatActivity() { + + private lateinit var qrCodeImage: ImageView + private lateinit var tvStatus: TextView + private lateinit var tvServerAddress: TextView + private lateinit var btnToggle: ToggleButton + + 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) + + qrCodeImage = findViewById(R.id.qr_code_image) + tvStatus = findViewById(R.id.tv_status) + tvServerAddress = findViewById(R.id.tv_server_address) + btnToggle = findViewById(R.id.btn_toggle_server) + + btnToggle.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + requestPermissionsAndStart() + } else { + stopServer() + } + } + + updateQrCode(null) + } + + 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) + } + // Sync toggle state with service running state + val running = DroidGuardServerService.isRunning + if (btnToggle.isChecked != running) { + btnToggle.setOnCheckedChangeListener(null) + btnToggle.isChecked = running + btnToggle.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) requestPermissionsAndStart() else stopServer() + } + } + if (!running) { + tvStatus.setText(R.string.status_idle) + tvServerAddress.text = "" + } + } + + override fun onPause() { + super.onPause() + try { + unregisterReceiver(statusReceiver) + } catch (ignored: IllegalArgumentException) { + } + } + + private fun requestPermissionsAndStart() { + // Request POST_NOTIFICATIONS on API 33+ + 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 + } + } + + // Request ignore battery optimizations (API 23+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + @Suppress("DEPRECATION") + val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager + if (!pm.isIgnoringBatteryOptimizations(packageName)) { + try { + startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + ) + } catch (e: Exception) { + Log.w(TAG, "Cannot request battery optimization exemption", e) + } + } + } + + 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) + tvServerAddress.text = "" + updateQrCode(null) + } + + private fun updateStatus(status: String, ip: String?, port: Int) { + when (status) { + DroidGuardServerService.STATUS_LISTENING -> { + tvStatus.text = getString(R.string.status_listening, ip, port) + tvServerAddress.text = getString(R.string.server_address_format, ip ?: "0.0.0.0", port) + updateQrCode(ip?.let { getString(R.string.server_address_format, it, port) }) + if (!btnToggle.isChecked) { + btnToggle.setOnCheckedChangeListener(null) + btnToggle.isChecked = true + btnToggle.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) requestPermissionsAndStart() else stopServer() + } + } + } + DroidGuardServerService.STATUS_PROCESSING -> { + tvStatus.setText(R.string.status_processing) + } + DroidGuardServerService.STATUS_STOPPED -> { + tvStatus.setText(R.string.status_idle) + tvServerAddress.text = "" + updateQrCode(null) + if (btnToggle.isChecked) { + btnToggle.setOnCheckedChangeListener(null) + btnToggle.isChecked = false + btnToggle.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) requestPermissionsAndStart() else stopServer() + } + } + } + } + } + + private fun updateQrCode(content: String?) { + if (content.isNullOrBlank()) { + qrCodeImage.setImageResource(android.R.drawable.ic_dialog_info) + return + } + try { + val size = 500 + val hints = mapOf(EncodeHintType.MARGIN to 1) + val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565) + for (x in 0 until size) { + for (y in 0 until size) { + bitmap.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE) + } + } + qrCodeImage.setImageBitmap(bitmap) + } catch (e: Exception) { + Log.e(TAG, "Failed to generate QR code", e) + } + } + + companion object { + private const val TAG = "DroidGuardServerMain" + private const val REQ_NOTIFICATION = 1001 + + /** Returns the device's first Tailscale IP (100.x.x.x in the CGNAT range). */ + fun getTailscaleIp(): String? { + return try { + NetworkInterface.getNetworkInterfaces()?.toList() + ?.flatMap { it.inetAddresses.toList() } + ?.firstOrNull { addr -> + !addr.isLoopbackAddress && addr is java.net.Inet4Address + && isTailscaleAddress(addr) + }?.hostAddress + } catch (e: Exception) { + null + } + } + + private fun isTailscaleAddress(addr: InetAddress): Boolean { + val bytes = addr.address + // Tailscale uses 100.64.0.0/10 (CGNAT range shared with Tailscale) + // First octet = 100, second octet 64..127 + return bytes[0] == 100.toByte() && (bytes[1].toInt() and 0xFF) 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/layout/activity_main.xml b/remote-droidguard-server/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000000..8e259bbeb8 --- /dev/null +++ b/remote-droidguard-server/src/main/res/layout/activity_main.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + 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..122bab0dc5 --- /dev/null +++ b/remote-droidguard-server/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + Remote DroidGuard Server + QR code for connecting to this DroidGuard server + Status: + Idle + Listening on %1$s:%2$d + Processing + Start Server + Stop Server + 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… + microg-dg://%1$s:%2$d + 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' From 90326b1c0de0d50ce4ca190173d2dc78010f1197 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:58:06 +0000 Subject: [PATCH 3/8] Remote DroidGuard Server: unique applicationId, custom icon, APK signing in CI Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> Agent-Logs-Url: https://github.com/samuel-asleep/GmsCore/sessions/955283be-d653-443b-a8e1-5e388e1a509d --- .github/workflows/build.yml | 26 +++++- remote-droidguard-server/build.gradle | 2 +- .../src/main/AndroidManifest.xml | 3 +- .../src/main/res/drawable/ic_launcher_dg.xml | 83 +++++++++++++++++++ .../src/main/res/values/strings.xml | 2 +- 5 files changed, 109 insertions(+), 7 deletions(-) create mode 100644 remote-droidguard-server/src/main/res/drawable/ic_launcher_dg.xml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 869d03817e..008a9099ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,6 +86,19 @@ jobs: run: "./gradlew ${{ matrix.assembleTask }}" - name: "Execute Gradle lint" run: "./gradlew ${{ matrix.lintTask }}" + - name: "Sign RemoteDroidGuard-Server APK" + if: matrix.target == 'Release' + id: sign_rdg_apk + uses: r0adkll/sign-android-release@v1 + continue-on-error: true + with: + releaseDirectory: remote-droidguard-server/build/outputs/apk/release + signingKeyBase64: ${{ secrets.SIGNING_KEY }} + alias: ${{ secrets.ALIAS }} + keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} + keyPassword: ${{ secrets.KEY_PASSWORD }} + env: + BUILD_TOOLS_VERSION: "34.0.0" - name: "Verify APK sizes" run: | # Find all output APKs (exclude intermediates) and verify none exceed 1 GB @@ -113,10 +126,15 @@ jobs: find play-services-core/build/outputs/apk/vtmDefault/release \ vending-app/build/outputs/apk/default/release \ -name '*.apk' -exec cp {} /tmp/release-apks/ \; - # Copy and rename the Remote DroidGuard Server APK - rdg_apk=$(find remote-droidguard-server/build/outputs/apk/release -name '*.apk' | head -1) - if [ -n "$rdg_apk" ]; then - cp "$rdg_apk" /tmp/release-apks/RemoteDroidGuard-Server.apk + # Use the signed APK if signing succeeded, otherwise fall back to unsigned + rdg_signed="${{ steps.sign_rdg_apk.outputs.signedReleaseFile }}" + if [ -n "$rdg_signed" ] && [ -f "$rdg_signed" ]; then + cp "$rdg_signed" /tmp/release-apks/RemoteDroidGuard-Server.apk + else + rdg_apk=$(find remote-droidguard-server/build/outputs/apk/release -name '*.apk' | head -1) + if [ -n "$rdg_apk" ]; then + cp "$rdg_apk" /tmp/release-apks/RemoteDroidGuard-Server.apk + fi fi - name: "Upload APK artifacts" if: matrix.target == 'Release' diff --git a/remote-droidguard-server/build.gradle b/remote-droidguard-server/build.gradle index 06d3c91c43..f9bd80a0cd 100644 --- a/remote-droidguard-server/build.gradle +++ b/remote-droidguard-server/build.gradle @@ -12,7 +12,7 @@ android { buildToolsVersion "$androidBuildVersionTools" defaultConfig { - applicationId "org.microg.gms.droidguard.server" + applicationId "org.microg.gms.droidguard.remote_server" versionName "1.0.0" versionCode 1 minSdkVersion 21 diff --git a/remote-droidguard-server/src/main/AndroidManifest.xml b/remote-droidguard-server/src/main/AndroidManifest.xml index f4a8fcfa0d..6dd26713f0 100644 --- a/remote-droidguard-server/src/main/AndroidManifest.xml +++ b/remote-droidguard-server/src/main/AndroidManifest.xml @@ -17,7 +17,8 @@ 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/values/strings.xml b/remote-droidguard-server/src/main/res/values/strings.xml index 122bab0dc5..3c9634957a 100644 --- a/remote-droidguard-server/src/main/res/values/strings.xml +++ b/remote-droidguard-server/src/main/res/values/strings.xml @@ -4,7 +4,7 @@ ~ SPDX-License-Identifier: Apache-2.0 --> - Remote DroidGuard Server + DG Server QR code for connecting to this DroidGuard server Status: Idle From 8d2e38a1ab42cff2af7e8a4253ffa8dd591433fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:12:39 +0000 Subject: [PATCH 4/8] build.yml: simplify to debug-only build, upload RemoteDroidGuard-Server-debug.apk Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> Agent-Logs-Url: https://github.com/samuel-asleep/GmsCore/sessions/d1c62d37-d645-4585-ba19-d52144373d38 --- .github/workflows/build.yml | 113 ++---------------------------------- 1 file changed, 6 insertions(+), 107 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 008a9099ee..21a791fb30 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,40 +13,16 @@ on: 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] - include: - # Debug: build all variants so every flavor is compile-checked. - - target: Debug - assembleTask: assembleDebug - lintTask: lintDebug - # Release: build only the primary open-source variant (vtm maps + - # default Android target) to produce a ~100 MB artifact instead of - # packaging all 15 flavor combinations (~1 GB). - - target: Release - assembleTask: >- - :play-services-core:assembleVtmDefaultRelease - :vending-app:assembleDefaultRelease - :remote-droidguard-server:assembleRelease - lintTask: >- - :play-services-core:lintVtmDefaultRelease - :vending-app:lintDefaultRelease - :remote-droidguard-server:lintRelease - 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 @@ -65,88 +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 ${{ matrix.assembleTask }}" - - name: "Execute Gradle lint" - run: "./gradlew ${{ matrix.lintTask }}" - - name: "Sign RemoteDroidGuard-Server APK" - if: matrix.target == 'Release' - id: sign_rdg_apk - uses: r0adkll/sign-android-release@v1 - continue-on-error: true - with: - releaseDirectory: remote-droidguard-server/build/outputs/apk/release - signingKeyBase64: ${{ secrets.SIGNING_KEY }} - alias: ${{ secrets.ALIAS }} - keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} - keyPassword: ${{ secrets.KEY_PASSWORD }} - env: - BUILD_TOOLS_VERSION: "34.0.0" - - name: "Verify APK sizes" - run: | - # Find all output APKs (exclude intermediates) and verify none exceed 1 GB - limit_bytes=$((1024 * 1024 * 1024)) - found=0 - oversized=0 - while IFS= read -r apk; do - size=$(wc -c < "$apk") - size_mb=$(( size / 1024 / 1024 )) - echo "APK: $apk size: ${size_mb} MB" - found=$(( found + 1 )) - if [ "$size" -gt "$limit_bytes" ]; then - echo "::error file=$apk::APK exceeds 1 GB limit (${size_mb} MB)" - oversized=$(( oversized + 1 )) - fi - done < <(find . -name '*.apk' -not -path '*/intermediates/*') - echo "Found $found APK(s), $oversized oversized." - if [ "$oversized" -gt 0 ]; then - exit 1 - fi - - name: "Stage release APKs" - if: matrix.target == 'Release' - run: | - mkdir -p /tmp/release-apks - find play-services-core/build/outputs/apk/vtmDefault/release \ - vending-app/build/outputs/apk/default/release \ - -name '*.apk' -exec cp {} /tmp/release-apks/ \; - # Use the signed APK if signing succeeded, otherwise fall back to unsigned - rdg_signed="${{ steps.sign_rdg_apk.outputs.signedReleaseFile }}" - if [ -n "$rdg_signed" ] && [ -f "$rdg_signed" ]; then - cp "$rdg_signed" /tmp/release-apks/RemoteDroidGuard-Server.apk - else - rdg_apk=$(find remote-droidguard-server/build/outputs/apk/release -name '*.apk' | head -1) - if [ -n "$rdg_apk" ]; then - cp "$rdg_apk" /tmp/release-apks/RemoteDroidGuard-Server.apk - fi - fi - - name: "Upload APK artifacts" - if: matrix.target == 'Release' + - name: "Assemble debug APK" + run: "./gradlew :remote-droidguard-server:assembleDebug" + - name: "Upload debug APK" uses: actions/upload-artifact@v4 with: - name: apk-release - path: /tmp/release-apks/ + name: RemoteDroidGuard-Server-debug.apk + path: remote-droidguard-server/build/outputs/apk/debug/*.apk if-no-files-found: error - - name: "Upload RemoteDroidGuard-Server.apk artifact" - if: matrix.target == 'Release' - uses: actions/upload-artifact@v4 - with: - name: RemoteDroidGuard-Server.apk - path: /tmp/release-apks/RemoteDroidGuard-Server.apk - if-no-files-found: warn From 6a6e4ad5548acf09de18f530c615bb4fd76cd372 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Mar 2026 02:36:40 +0000 Subject: [PATCH 5/8] feat: Material UI redesign, Tailscale detection & error handling for Remote DroidGuard Server Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> Agent-Logs-Url: https://github.com/samuel-asleep/GmsCore/sessions/f90967e8-c188-4e0c-85ba-bab945384146 --- .../src/main/AndroidManifest.xml | 2 +- .../gms/droidguard/server/MainActivity.kt | 163 ++++++++++++------ .../src/main/res/anim/pulse.xml | 12 ++ .../main/res/drawable/bg_status_indicator.xml | 9 + .../src/main/res/layout/activity_main.xml | 140 +++++++++------ .../src/main/res/values/strings.xml | 10 +- 6 files changed, 235 insertions(+), 101 deletions(-) create mode 100644 remote-droidguard-server/src/main/res/anim/pulse.xml create mode 100644 remote-droidguard-server/src/main/res/drawable/bg_status_indicator.xml diff --git a/remote-droidguard-server/src/main/AndroidManifest.xml b/remote-droidguard-server/src/main/AndroidManifest.xml index 6dd26713f0..20dfb6034a 100644 --- a/remote-droidguard-server/src/main/AndroidManifest.xml +++ b/remote-droidguard-server/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ android:icon="@drawable/ic_launcher_dg" android:roundIcon="@drawable/ic_launcher_dg" android:label="@string/app_name" - android:theme="@style/Theme.AppCompat.Light.DarkActionBar"> + android:theme="@style/Theme.MaterialComponents.Light.DarkActionBar"> - if (isChecked) { + btnToggle.setOnClickListener { + if (!serverRunning) { requestPermissionsAndStart() } else { stopServer() } } + updateButtonState(false) updateQrCode(null) + checkTailscaleInstalled() } override fun onResume() { @@ -79,18 +91,15 @@ class MainActivity : AppCompatActivity() { @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(statusReceiver, filter) } - // Sync toggle state with service running state val running = DroidGuardServerService.isRunning - if (btnToggle.isChecked != running) { - btnToggle.setOnCheckedChangeListener(null) - btnToggle.isChecked = running - btnToggle.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) requestPermissionsAndStart() else stopServer() - } + if (running != serverRunning) { + updateButtonState(running) } if (!running) { tvStatus.setText(R.string.status_idle) tvServerAddress.text = "" + tvWarning.visibility = View.GONE + setIndicatorActive(false) } } @@ -102,8 +111,35 @@ class MainActivity : AppCompatActivity() { } } + // ---- Tailscale check --------------------------------------------------- + + private fun checkTailscaleInstalled() { + try { + packageManager.getPackageInfo("com.tailscale.ipn", 0) + } catch (e: PackageManager.NameNotFoundException) { + showTailscaleRequiredDialog() + } + } + + private fun showTailscaleRequiredDialog() { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.dialog_tailscale_title) + .setMessage(R.string.dialog_tailscale_message) + .setPositiveButton(R.string.dialog_tailscale_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) + } + .setNegativeButton(R.string.dialog_tailscale_dismiss, null) + .show() + } + + // ---- Permissions & server start ---------------------------------------- + private fun requestPermissionsAndStart() { - // Request POST_NOTIFICATIONS on API 33+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED @@ -113,7 +149,6 @@ class MainActivity : AppCompatActivity() { } } - // Request ignore battery optimizations (API 23+) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @Suppress("DEPRECATION") val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager @@ -149,6 +184,7 @@ class MainActivity : AppCompatActivity() { startService(intent) } tvStatus.setText(R.string.status_idle) + tvServerAddress.text = getString(R.string.searching_tailscale_ip) } private fun stopServer() { @@ -157,22 +193,31 @@ class MainActivity : AppCompatActivity() { startService(intent) tvStatus.setText(R.string.status_idle) tvServerAddress.text = "" + tvWarning.visibility = View.GONE updateQrCode(null) + updateButtonState(false) + setIndicatorActive(false) } + // ---- Status updates ---------------------------------------------------- + private fun updateStatus(status: String, ip: String?, port: Int) { when (status) { DroidGuardServerService.STATUS_LISTENING -> { - tvStatus.text = getString(R.string.status_listening, ip, port) - tvServerAddress.text = getString(R.string.server_address_format, ip ?: "0.0.0.0", port) - updateQrCode(ip?.let { getString(R.string.server_address_format, it, port) }) - if (!btnToggle.isChecked) { - btnToggle.setOnCheckedChangeListener(null) - btnToggle.isChecked = true - btnToggle.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) requestPermissionsAndStart() else stopServer() - } + if (ip != null) { + tvStatus.text = getString(R.string.status_listening, ip, port) + tvServerAddress.text = getString(R.string.server_address_format, ip, port) + updateQrCode(getString(R.string.server_address_format, ip, port)) + tvWarning.visibility = View.GONE + } else { + tvStatus.setText(R.string.status_listening_no_ip) + tvServerAddress.text = getString(R.string.searching_tailscale_ip) + updateQrCode(null) + 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) @@ -180,33 +225,53 @@ class MainActivity : AppCompatActivity() { DroidGuardServerService.STATUS_STOPPED -> { tvStatus.setText(R.string.status_idle) tvServerAddress.text = "" + tvWarning.visibility = View.GONE updateQrCode(null) - if (btnToggle.isChecked) { - btnToggle.setOnCheckedChangeListener(null) - btnToggle.isChecked = false - btnToggle.setOnCheckedChangeListener { _, isChecked -> - if (isChecked) requestPermissionsAndStart() else stopServer() - } - } + updateButtonState(false) + setIndicatorActive(false) } } } + // ---- 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 + } + } + private fun updateQrCode(content: String?) { if (content.isNullOrBlank()) { qrCodeImage.setImageResource(android.R.drawable.ic_dialog_info) return } try { - val size = 500 + val size = 1024 val hints = mapOf(EncodeHintType.MARGIN to 1) val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) - val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565) - for (x in 0 until size) { - for (y in 0 until size) { - bitmap.setPixel(x, y, if (bitMatrix[x, y]) Color.BLACK else Color.WHITE) - } + val pixels = IntArray(size * size) { i -> + if (bitMatrix[i % size, i / size]) Color.BLACK else Color.WHITE } + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + bitmap.setPixels(pixels, 0, size, 0, 0, size, size) qrCodeImage.setImageBitmap(bitmap) } catch (e: Exception) { Log.e(TAG, "Failed to generate QR code", e) @@ -217,25 +282,21 @@ class MainActivity : AppCompatActivity() { private const val TAG = "DroidGuardServerMain" private const val REQ_NOTIFICATION = 1001 - /** Returns the device's first Tailscale IP (100.x.x.x in the CGNAT range). */ + /** + * Returns the device's Tailscale IP by looking specifically at the + * 'tailscale0' network interface. Returns null if Tailscale is not + * running or no IPv4 address is assigned to that interface. + */ fun getTailscaleIp(): String? { return try { - NetworkInterface.getNetworkInterfaces()?.toList() - ?.flatMap { it.inetAddresses.toList() } - ?.firstOrNull { addr -> - !addr.isLoopbackAddress && addr is java.net.Inet4Address - && isTailscaleAddress(addr) - }?.hostAddress + val iface = NetworkInterface.getByName("tailscale0") ?: return null + iface.inetAddresses.toList() + .firstOrNull { !it.isLoopbackAddress && it is Inet4Address } + ?.hostAddress } catch (e: Exception) { + Log.w(TAG, "Could not read tailscale0 interface", e) null } } - - private fun isTailscaleAddress(addr: InetAddress): Boolean { - val bytes = addr.address - // Tailscale uses 100.64.0.0/10 (CGNAT range shared with Tailscale) - // First octet = 100, second octet 64..127 - return bytes[0] == 100.toByte() && (bytes[1].toInt() and 0xFF) in 64..127 - } } } 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/layout/activity_main.xml b/remote-droidguard-server/src/main/res/layout/activity_main.xml index 8e259bbeb8..7efba36420 100644 --- a/remote-droidguard-server/src/main/res/layout/activity_main.xml +++ b/remote-droidguard-server/src/main/res/layout/activity_main.xml @@ -3,67 +3,111 @@ ~ SPDX-FileCopyrightText: 2025 microG Project Team ~ SPDX-License-Identifier: Apache-2.0 --> - + android:layout_height="match_parent"> - + android:gravity="center_horizontal" + android:orientation="vertical" + android:padding="24dp"> - + + - + - + + + + + + + + + + android:layout_marginBottom="8dp" + android:textAppearance="?attr/textAppearanceBody2" /> + + + + + + + + + - + android:layout_marginBottom="16dp" + android:textAppearance="?attr/textAppearanceBody2" + android:textColor="#D32F2F" + android:visibility="gone" /> - + + - + + diff --git a/remote-droidguard-server/src/main/res/values/strings.xml b/remote-droidguard-server/src/main/res/values/strings.xml index 3c9634957a..57de6e4517 100644 --- a/remote-droidguard-server/src/main/res/values/strings.xml +++ b/remote-droidguard-server/src/main/res/values/strings.xml @@ -5,18 +5,26 @@ --> DG Server + DroidGuard Server Status QR code for connecting to this DroidGuard server Status: Idle Listening on %1$s:%2$d + Listening (no Tailscale IP) Processing Start Server Stop Server + Searching for Tailscale IP\u2026 + 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… + Processing a DroidGuard challenge\u2026 microg-dg://%1$s:%2$d + Tailscale Required + This app uses Tailscale to expose the DroidGuard server over a secure private network. Please install Tailscale to enable remote connections. + Install Tailscale + Dismiss From 5916fc93286d59f8356f38d25d3852a8174cccbb Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:51:04 +0000 Subject: [PATCH 6/8] refactor: Simplified UI with Tailscale IP detection fix - Fixed Tailscale IP detection to enumerate all interfaces and check 100.64.0.0/10 CGNAT range - Removed QR code and ZXing dependency for cleaner, lightweight UI - Added large Tailscale IP display in MaterialCardView with copy-to-clipboard button - Implemented smart Tailscale helper logic (install/open prompts based on app state) - Updated UI to show real-time IP status with clear messaging for each state - Status indicator and warning banner for connection status visibility Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> Agent-Logs-Url: https://github.com/samuel-asleep/GmsCore/sessions/5805ae1d-0fe9-4b37-8af2-bed5003aa121 --- remote-droidguard-server/build.gradle | 2 - .../gms/droidguard/server/MainActivity.kt | 188 ++++++++++++------ .../src/main/res/layout/activity_main.xml | 67 +++++-- .../src/main/res/values/strings.xml | 18 +- 4 files changed, 181 insertions(+), 94 deletions(-) diff --git a/remote-droidguard-server/build.gradle b/remote-droidguard-server/build.gradle index f9bd80a0cd..b1fa9a7b41 100644 --- a/remote-droidguard-server/build.gradle +++ b/remote-droidguard-server/build.gradle @@ -52,8 +52,6 @@ dependencies { implementation project(':play-services-droidguard') implementation project(':play-services-tasks-ktx') - implementation "com.google.zxing:core:3.5.3" - implementation "androidx.core:core-ktx:$coreVersion" implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation "com.google.android.material:material:$materialVersion" 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 index 6552141435..8cb004b6ce 100644 --- 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 @@ -7,12 +7,13 @@ 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.pm.PackageManager import android.content.res.ColorStateList -import android.graphics.Bitmap import android.graphics.Color import android.net.Uri import android.os.Build @@ -23,27 +24,27 @@ 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.zxing.BarcodeFormat -import com.google.zxing.EncodeHintType -import com.google.zxing.qrcode.QRCodeWriter import java.net.Inet4Address import java.net.NetworkInterface class MainActivity : AppCompatActivity() { - private lateinit var qrCodeImage: ImageView + private lateinit var tvTailscaleIp: TextView + private lateinit var btnCopyIp: MaterialButton private lateinit var tvStatus: TextView - private lateinit var tvServerAddress: TextView private lateinit var tvWarning: TextView private lateinit var btnToggle: MaterialButton + private lateinit var btnTailscaleAction: MaterialButton private lateinit var statusIndicator: View private var serverRunning = false + private var currentIp: String? = null private val statusReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -62,11 +63,12 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - qrCodeImage = findViewById(R.id.qr_code_image) + tvTailscaleIp = findViewById(R.id.tv_tailscale_ip) + btnCopyIp = findViewById(R.id.btn_copy_ip) tvStatus = findViewById(R.id.tv_status) - tvServerAddress = findViewById(R.id.tv_server_address) tvWarning = findViewById(R.id.tv_warning) btnToggle = findViewById(R.id.btn_toggle_server) + btnTailscaleAction = findViewById(R.id.btn_tailscale_action) statusIndicator = findViewById(R.id.status_indicator) btnToggle.setOnClickListener { @@ -77,9 +79,16 @@ class MainActivity : AppCompatActivity() { } } + btnCopyIp.setOnClickListener { + copyIpToClipboard() + } + + btnTailscaleAction.setOnClickListener { + openTailscaleApp() + } + updateButtonState(false) - updateQrCode(null) - checkTailscaleInstalled() + checkTailscaleAndUpdateUI() } override fun onResume() { @@ -97,10 +106,10 @@ class MainActivity : AppCompatActivity() { } if (!running) { tvStatus.setText(R.string.status_idle) - tvServerAddress.text = "" tvWarning.visibility = View.GONE setIndicatorActive(false) } + checkTailscaleAndUpdateUI() } override fun onPause() { @@ -111,30 +120,74 @@ class MainActivity : AppCompatActivity() { } } - // ---- Tailscale check --------------------------------------------------- + // ---- Tailscale check & UI update -------------------------------------- - private fun checkTailscaleInstalled() { - try { + 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) { - showTailscaleRequiredDialog() + false } } - private fun showTailscaleRequiredDialog() { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.dialog_tailscale_title) - .setMessage(R.string.dialog_tailscale_message) - .setPositiveButton(R.string.dialog_tailscale_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) + 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")) } - .setNegativeButton(R.string.dialog_tailscale_dismiss, null) - .show() + 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 ---------------------------------------- @@ -184,7 +237,6 @@ class MainActivity : AppCompatActivity() { startService(intent) } tvStatus.setText(R.string.status_idle) - tvServerAddress.text = getString(R.string.searching_tailscale_ip) } private fun stopServer() { @@ -192,9 +244,7 @@ class MainActivity : AppCompatActivity() { .setAction(DroidGuardServerService.ACTION_STOP) startService(intent) tvStatus.setText(R.string.status_idle) - tvServerAddress.text = "" tvWarning.visibility = View.GONE - updateQrCode(null) updateButtonState(false) setIndicatorActive(false) } @@ -206,13 +256,15 @@ class MainActivity : AppCompatActivity() { DroidGuardServerService.STATUS_LISTENING -> { if (ip != null) { tvStatus.text = getString(R.string.status_listening, ip, port) - tvServerAddress.text = getString(R.string.server_address_format, ip, port) - updateQrCode(getString(R.string.server_address_format, 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) - tvServerAddress.text = getString(R.string.searching_tailscale_ip) - updateQrCode(null) + 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 } @@ -224,11 +276,10 @@ class MainActivity : AppCompatActivity() { } DroidGuardServerService.STATUS_STOPPED -> { tvStatus.setText(R.string.status_idle) - tvServerAddress.text = "" tvWarning.visibility = View.GONE - updateQrCode(null) updateButtonState(false) setIndicatorActive(false) + checkTailscaleAndUpdateUI() } } } @@ -258,45 +309,52 @@ class MainActivity : AppCompatActivity() { } } - private fun updateQrCode(content: String?) { - if (content.isNullOrBlank()) { - qrCodeImage.setImageResource(android.R.drawable.ic_dialog_info) - return - } - try { - val size = 1024 - val hints = mapOf(EncodeHintType.MARGIN to 1) - val bitMatrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) - val pixels = IntArray(size * size) { i -> - if (bitMatrix[i % size, i / size]) Color.BLACK else Color.WHITE - } - val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) - bitmap.setPixels(pixels, 0, size, 0, 0, size, size) - qrCodeImage.setImageBitmap(bitmap) - } catch (e: Exception) { - Log.e(TAG, "Failed to generate QR code", e) - } - } - companion object { private const val TAG = "DroidGuardServerMain" private const val REQ_NOTIFICATION = 1001 /** - * Returns the device's Tailscale IP by looking specifically at the - * 'tailscale0' network interface. Returns null if Tailscale is not - * running or no IPv4 address is assigned to that interface. + * 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 iface = NetworkInterface.getByName("tailscale0") ?: return null - iface.inetAddresses.toList() - .firstOrNull { !it.isLoopbackAddress && it is Inet4Address } - ?.hostAddress + 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 read tailscale0 interface", e) + 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/res/layout/activity_main.xml b/remote-droidguard-server/src/main/res/layout/activity_main.xml index 7efba36420..d5bfc110b3 100644 --- a/remote-droidguard-server/src/main/res/layout/activity_main.xml +++ b/remote-droidguard-server/src/main/res/layout/activity_main.xml @@ -38,33 +38,58 @@ android:textStyle="bold" /> - + - - + app:contentPadding="20dp"> + + + + + + + + + - - + + android:layout_marginBottom="16dp" + android:text="@string/btn_install_tailscale" + android:visibility="gone" + app:icon="@android:drawable/ic_menu_info_details" + style="@style/Widget.MaterialComponents.Button.OutlinedButton" /> diff --git a/remote-droidguard-server/src/main/res/values/strings.xml b/remote-droidguard-server/src/main/res/values/strings.xml index 57de6e4517..44fed1e064 100644 --- a/remote-droidguard-server/src/main/res/values/strings.xml +++ b/remote-droidguard-server/src/main/res/values/strings.xml @@ -6,7 +6,6 @@ DG Server DroidGuard Server Status - QR code for connecting to this DroidGuard server Status: Idle Listening on %1$s:%2$d @@ -14,7 +13,6 @@ Processing Start Server Stop Server - Searching for Tailscale IP\u2026 Warning: No Tailscale IP detected. Local connections only. DroidGuard Server Foreground notification for the Remote DroidGuard Server @@ -23,8 +21,16 @@ Listening on %1$s:%2$d Processing a DroidGuard challenge\u2026 microg-dg://%1$s:%2$d - Tailscale Required - This app uses Tailscale to expose the DroidGuard server over a secure private network. Please install Tailscale to enable remote connections. - Install Tailscale - Dismiss + + + 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 From 43d9a541b4138b82d74a77ec7e25a35c8246705e Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:12:09 +0000 Subject: [PATCH 7/8] feat: Proactive battery optimization & boot start toggle - Moved battery optimization check to onCreate() with first-launch dialog - Added "Start Server on Boot" MaterialSwitch with SharedPreferences (default: true) - Updated BootReceiver to check SharedPreferences before auto-starting - Dialog prompts user to set Battery to 'Unrestricted' on first launch - Start/Stop button no longer triggers permission dialogs (handled at app launch) Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> Agent-Logs-Url: https://github.com/samuel-asleep/GmsCore/sessions/79116a82-17a0-4b79-ac6d-433d4de7c33f --- .../gms/droidguard/server/BootReceiver.kt | 11 ++- .../gms/droidguard/server/MainActivity.kt | 77 +++++++++++++++---- .../src/main/res/layout/activity_main.xml | 39 ++++++++++ .../src/main/res/values/strings.xml | 11 +++ 4 files changed, 121 insertions(+), 17 deletions(-) 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 index 5d2ac9ad5c..c125e76e4b 100644 --- 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 @@ -13,7 +13,7 @@ import android.util.Log /** * Starts [DroidGuardServerService] automatically when the device boots, - * if it was running before the reboot. + * if the user has enabled "Start Server on Boot" in SharedPreferences. */ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -21,6 +21,15 @@ class BootReceiver : BroadcastReceiver() { 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) 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 index 8cb004b6ce..6835bbe609 100644 --- 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 @@ -12,6 +12,7 @@ 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 @@ -30,6 +31,7 @@ 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 @@ -41,11 +43,14 @@ class MainActivity : AppCompatActivity() { 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) { @@ -63,14 +68,24 @@ class MainActivity : AppCompatActivity() { 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() @@ -89,6 +104,9 @@ class MainActivity : AppCompatActivity() { updateButtonState(false) checkTailscaleAndUpdateUI() + + // Check battery optimization on first launch + checkBatteryOptimizationOnFirstLaunch() } override fun onResume() { @@ -122,6 +140,45 @@ class MainActivity : AppCompatActivity() { // ---- 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() @@ -202,22 +259,6 @@ class MainActivity : AppCompatActivity() { } } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - @Suppress("DEPRECATION") - val pm = getSystemService(POWER_SERVICE) as android.os.PowerManager - if (!pm.isIgnoringBatteryOptimizations(packageName)) { - try { - startActivity( - Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { - data = Uri.parse("package:$packageName") - } - ) - } catch (e: Exception) { - Log.w(TAG, "Cannot request battery optimization exemption", e) - } - } - } - startServer() } @@ -313,6 +354,10 @@ class MainActivity : AppCompatActivity() { 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). diff --git a/remote-droidguard-server/src/main/res/layout/activity_main.xml b/remote-droidguard-server/src/main/res/layout/activity_main.xml index d5bfc110b3..2a8a2aef84 100644 --- a/remote-droidguard-server/src/main/res/layout/activity_main.xml +++ b/remote-droidguard-server/src/main/res/layout/activity_main.xml @@ -91,6 +91,45 @@ app:icon="@android:drawable/ic_menu_info_details" style="@style/Widget.MaterialComponents.Button.OutlinedButton" /> + + + + + + + + + + + + + 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 From 04e3c05abdc9324458d0ebd37921082fb69b88ee Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Sat, 28 Mar 2026 13:39:57 +0000 Subject: [PATCH 8/8] feat: Add multi-step DroidGuard session support to RemoteHandleImpl - Added sessionId field to track server-side session state - Updated init() and initWithRequest() to call POST /v2/init and store sessionId - Modified snapshot() to use /v2/snapshot with sessionId in multi-step mode - Added fallback to single-shot / endpoint for backwards compatibility - Updated close() to call POST /v2/close to clean up server session - Extracted helper methods: callInitEndpoint(), snapshotMultiStep(), snapshotSingleShot(), closeSession() - Maintains backwards compatibility with older servers that don't support /v2 endpoints This enables Play Integrity to work with remote DroidGuard by preserving session state across multiple snapshot() calls. Agent-Logs-Url: https://github.com/samuel-asleep/GmsCore/sessions/db326df7-a3da-4af0-8765-b369ea77a832 Co-authored-by: samuel-asleep <210051637+samuel-asleep@users.noreply.github.com> --- .../gms/droidguard/core/RemoteHandleImpl.kt | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) 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