Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 4f2595cf authored by Jonathan Klee's avatar Jonathan Klee
Browse files

feat: introduce hide microG feature

parent ec3706f2
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -137,6 +137,7 @@ android {
        buildConfigField "String", "RECAPTCHA_ENTERPRISE_PROJECT_ID", "\"${localProperties.get("recaptchaEnterpreise.projectId", "")}\""
        buildConfigField "String", "RECAPTCHA_ENTERPRISE_SITE_KEY", "\"${localProperties.get("recaptchaEnterpreise.siteKey", "")}\""
        buildConfigField "String", "RECAPTCHA_ENTERPRISE_API_KEY", "\"${localProperties.get("recaptchaEnterpreise.apiKey", "")}\""
        buildConfigField "String", "HIDE_MICROG_PACKAGES_URL", "\"${localProperties.getProperty("hidemicrog.url", System.getenv('HIDE_MICROG_PACKAGES_URL') ?: "")}\""
    }

    splits {
+11 −0
Original line number Diff line number Diff line
@@ -15,6 +15,9 @@
  -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission
        android:name="android.permission.WRITE_SECURE_SETTINGS"
        tools:ignore="ProtectedPermissions" />
    <uses-permission android:name="android.permission.PROVIDE_DEFAULT_ENABLED_CREDENTIAL_SERVICE"
        tools:ignore="ProtectedPermissions" />

@@ -427,6 +430,14 @@
            </intent-filter>
        </receiver>

        <receiver
            android:name="org.microg.gms.ui.HideMicrogSyncReceiver">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
                <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
            </intent-filter>
        </receiver>

        <!-- Car -->

        <service android:name="org.microg.gms.car.CarService">
+186 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2026 microG Project Team
 * SPDX-License-Identifier: Apache-2.0
 */

package org.microg.gms.ui

import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import com.google.android.gms.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class HideMicrogFragment : PreferenceFragmentCompat() {
    private lateinit var mode: ListPreference
    private lateinit var progress: Preference
    private lateinit var apps: PreferenceCategory
    private lateinit var none: Preference
    private val selectedPackages = linkedSetOf<String>()

    override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
        addPreferencesFromResource(R.xml.preferences_hide_microg)
        mode = preferenceScreen.findPreference("pref_hide_microg_mode") ?: mode
        progress = preferenceScreen.findPreference("pref_hide_microg_progress") ?: progress
        apps = preferenceScreen.findPreference("prefcat_hide_microg_apps") ?: apps
        none = preferenceScreen.findPreference("pref_hide_microg_none") ?: none
        configureStaticPreferences()
    }

    override fun onResume() {
        super.onResume()
        updatePreferenceState()
        updateContent()
    }

    private fun configureStaticPreferences() {
        val context = requireContext()
        mode.isPersistent = false
        mode.entries = arrayOf(
            getString(R.string.pref_hide_microg_mode_manual),
            getString(R.string.pref_hide_microg_mode_automatic)
        )
        mode.entryValues = arrayOf(HideMicrogSettings.MODE_MANUAL, HideMicrogSettings.MODE_AUTOMATIC)
        mode.value = HideMicrogSettings.getMode(context)
        mode.setOnPreferenceChangeListener { _, newValue ->
            val newMode = newValue as? String ?: return@setOnPreferenceChangeListener false
            HideMicrogSettings.setMode(context, newMode)
            updatePreferenceState(newMode)
            updateContent(showFeedback = newMode == HideMicrogSettings.MODE_AUTOMATIC)
            true
        }
    }

    private fun updatePreferenceState(
        modeValue: String = HideMicrogSettings.getMode(requireContext())
    ) {
        val automaticMode = modeValue == HideMicrogSettings.MODE_AUTOMATIC
        mode.summary = if (automaticMode) {
            getString(R.string.pref_hide_microg_mode_automatic)
        } else {
            getString(R.string.pref_hide_microg_mode_manual)
        }
    }

    private fun updateContent(showFeedback: Boolean = false) {
        lifecycleScope.launchWhenResumed {
            val context = requireContext()
            val automaticMode = HideMicrogSettings.isAutomaticMode(context)
            updatePreferenceState(HideMicrogSettings.getMode(context))
            progress.isVisible = true

            val selected = try {
                if (automaticMode) {
                    HideMicrogSettings.syncPackagesFromUrl(context)
                } else {
                    HideMicrogSettings.readPackages(context)
                }
            } catch (e: Exception) {
                if (showFeedback || automaticMode) {
                    Toast.makeText(
                        context,
                        e.message?.takeIf { it.isNotBlank() } ?: getString(R.string.pref_hide_microg_fetch_failed),
                        Toast.LENGTH_LONG
                    ).show()
                }
                HideMicrogSettings.readPackages(context)
            }

            selectedPackages.clear()
            selectedPackages.addAll(selected)

            val appRows = loadAppRows(context, selected)
            renderAppRows(
                appRows = appRows,
                automaticMode = automaticMode,
                emptyMessage = getString(org.microg.gms.base.core.R.string.list_no_item_none)
            )
            progress.isVisible = false
        }
    }

    private suspend fun loadAppRows(context: Context, selected: Set<String>): List<AppRow> = withContext(Dispatchers.IO) {
        val packageManager = context.packageManager
        packageManager.getInstalledApplications(PackageManager.GET_META_DATA)
            .asSequence()
            .filter { applicationInfo ->
                applicationInfo.packageName != context.packageName &&
                    applicationInfo.flags and (ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0
            }
            .map { applicationInfo ->
                val label = applicationInfo.loadLabel(packageManager).toString().trim()
                val displayName = if (label.isNotEmpty()) label else applicationInfo.packageName
                AppRow(
                    packageName = applicationInfo.packageName,
                    label = displayName
                )
            }
            .sortedWith(compareByDescending<AppRow> { it.packageName in selected }
                .thenBy { it.label.lowercase() }
                .thenBy { it.packageName })
            .toList()
    }

    private fun renderAppRows(appRows: List<AppRow>, automaticMode: Boolean, emptyMessage: String) {
        apps.removeAll()
        apps.isVisible = true

        if (appRows.isEmpty()) {
            none.title = emptyMessage
            none.isVisible = true
            apps.addPreference(none)
            return
        }

        none.isVisible = false
        appRows.forEachIndexed { index, app ->
            apps.addPreference(createAppPreference(app, index, automaticMode))
        }
    }

    private fun createAppPreference(app: AppRow, order: Int, automaticMode: Boolean): CheckBoxPreference {
        return CheckBoxPreference(requireContext()).apply {
            key = "pref_hide_microg_${app.packageName}"
            title = app.label
            summary = app.packageName
            icon = requireContext().packageManager.getApplicationIcon(app.packageName)
            isPersistent = false
            isChecked = app.packageName in selectedPackages
            isEnabled = !automaticMode
            this.order = order
            if (!automaticMode) {
                setOnPreferenceChangeListener { preference, newValue ->
                    if (newValue !is Boolean) return@setOnPreferenceChangeListener false
                    val updatedPackages = selectedPackages.toMutableSet().apply {
                        if (newValue) add(app.packageName) else remove(app.packageName)
                    }
                    val success = runCatching {
                        HideMicrogSettings.writePackages(preference.context, updatedPackages)
                    }.getOrDefault(false)
                    if (!success) {
                        Toast.makeText(preference.context, R.string.pref_hide_microg_write_failed, Toast.LENGTH_LONG).show()
                        return@setOnPreferenceChangeListener false
                    }
                    selectedPackages.clear()
                    selectedPackages.addAll(updatedPackages)
                    updateContent()
                    true
                }
            }
        }
    }

    private data class AppRow(
        val packageName: String,
        val label: String
    )
}
+195 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2026 microG Project Team
 * SPDX-License-Identifier: Apache-2.0
 */

package org.microg.gms.ui

import android.content.Context
import android.provider.Settings
import androidx.preference.PreferenceManager
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import com.google.android.gms.BuildConfig
import com.google.android.gms.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import java.util.TreeSet
import java.util.concurrent.TimeUnit

object HideMicrogSettings {
    const val KEY = "hide_microg_packages_for_package"
    const val MODE_MANUAL = "manual"
    const val MODE_AUTOMATIC = "automatic"

    private const val PREF_MODE = "pref_hide_microg_mode"
    private val DEFAULT_URL = BuildConfig.HIDE_MICROG_PACKAGES_URL.trim()
    private val PACKAGE_NAME_REGEX = Regex("^[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+$")

    fun readPackages(context: Context): Set<String> {
        val rawValue = Settings.System.getString(context.contentResolver, KEY).orEmpty()
        return rawValue
            .split(',', ';', '\n')
            .map { it.trim() }
            .filter { it.isNotEmpty() }
            .toCollection(TreeSet())
    }

    fun writePackages(context: Context, packages: Set<String>): Boolean {
        val normalized = packages
            .map { it.trim() }
            .filter { it.isNotEmpty() }
            .toSortedSet()
            .joinToString(",")
        return Settings.System.putString(context.contentResolver, KEY, normalized)
    }

    fun getMode(context: Context): String {
        val value = PreferenceManager.getDefaultSharedPreferences(context)
            .getString(PREF_MODE, MODE_AUTOMATIC)
            .orEmpty()
        return if (value == MODE_AUTOMATIC) MODE_AUTOMATIC else MODE_MANUAL
    }

    fun setMode(context: Context, mode: String) {
        PreferenceManager.getDefaultSharedPreferences(context)
            .edit()
            .putString(PREF_MODE, if (mode == MODE_AUTOMATIC) MODE_AUTOMATIC else MODE_MANUAL)
            .apply()
    }

    fun getUrl(): String = DEFAULT_URL

    fun isAutomaticMode(context: Context): Boolean = getMode(context) == MODE_AUTOMATIC

    fun isSupportedUrl(url: String): Boolean = url.startsWith("https://") || url.startsWith("http://")

    fun enqueueAutomaticSync(context: Context) {
        if (!isAutomaticMode(context)) return
        val url = getUrl()
        if (url.isBlank() || !isSupportedUrl(url)) return

        val constraints = Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
        val request = OneTimeWorkRequestBuilder<HideMicrogSyncWorker>()
            .setConstraints(constraints)
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
            .build()
        WorkManager.getInstance(context.applicationContext).enqueueUniqueWork(
            HideMicrogSyncWorker.WORK_NAME,
            ExistingWorkPolicy.REPLACE,
            request
        )
    }

    suspend fun syncPackagesFromUrl(context: Context): Set<String> = withContext(Dispatchers.IO) {
        val url = getUrl()
        if (url.isBlank() || !isSupportedUrl(url)) {
            throw IOException(context.getString(R.string.pref_hide_microg_fetch_failed))
        }

        val packages = fetchPackages(url)
        if (!writePackages(context, packages)) {
            throw IOException(context.getString(R.string.pref_hide_microg_write_failed))
        }
        packages
    }

    fun getSummary(context: Context): String {
        val count = readPackages(context).size
        return if (count == 0) {
            context.getString(R.string.pref_hide_microg_summary_none)
        } else {
            context.resources.getQuantityString(R.plurals.pref_hide_microg_summary_count, count, count)
        }
    }

    private fun fetchPackages(urlString: String): Set<String> {
        val connection = (URL(urlString).openConnection() as HttpURLConnection).apply {
            requestMethod = "GET"
            doInput = true
            connectTimeout = 15000
            readTimeout = 15000
            setRequestProperty("Accept", "application/json, text/plain;q=0.9, */*;q=0.8")
        }

        return try {
            val responseCode = connection.responseCode
            val responseBody = (if (responseCode in 200..299) connection.inputStream else connection.errorStream)
                ?.bufferedReader()
                ?.use { it.readText() }
                .orEmpty()
            if (responseCode !in 200..299) {
                throw IOException(responseBody.ifBlank { connection.responseMessage?.takeIf { it.isNotBlank() } ?: "HTTP $responseCode" })
            }
            parsePackageResponse(responseBody)
        } finally {
            connection.disconnect()
        }
    }

    private fun parsePackageResponse(responseBody: String): Set<String> {
        val trimmed = responseBody.trim()
        if (trimmed.isEmpty()) return emptySet()
        return runCatching { parseJsonPackages(trimmed) }.getOrElse { parseDelimitedPackages(trimmed) }
    }

    private fun parseJsonPackages(rawValue: String): Set<String> {
        if (rawValue.startsWith("[")) {
            return normalizePackages(readJsonArray(JSONArray(rawValue)))
        }
        if (!rawValue.startsWith("{")) {
            throw IOException("Response is not JSON")
        }

        val jsonObject = JSONObject(rawValue)
        for (key in listOf("packages", "apps", "packageNames", "items")) {
            val array = jsonObject.optJSONArray(key) ?: continue
            return normalizePackages(readJsonArray(array))
        }
        throw IOException("No package list found in JSON response")
    }

    private fun readJsonArray(array: JSONArray): Sequence<String> = sequence {
        for (index in 0 until array.length()) {
            when (val value = array.opt(index)) {
                is String -> yield(value)
                is JSONObject -> {
                    val packageName = value.optString("packageName")
                        .ifBlank { value.optString("package") }
                    if (packageName.isNotBlank()) yield(packageName)
                }
            }
        }
    }

    private fun parseDelimitedPackages(rawValue: String): Set<String> = normalizePackages(
        rawValue.lineSequence().flatMap { line ->
            line.substringBefore('#').split(',', ';').asSequence()
        }
    )

    private fun normalizePackages(packages: Sequence<String>): Set<String> {
        val tokens = packages
            .map { it.trim().removeSurrounding("\"").removeSurrounding("'") }
            .filter { it.isNotBlank() }
            .toList()
        val normalized = tokens
            .filter { PACKAGE_NAME_REGEX.matches(it) }
            .toCollection(TreeSet())
        if (tokens.isNotEmpty() && normalized.isEmpty()) {
            throw IOException("No valid package names found in response")
        }
        return normalized
    }
}
+27 −0
Original line number Diff line number Diff line
/*
 * SPDX-FileCopyrightText: 2026 microG Project Team
 * SPDX-License-Identifier: Apache-2.0
 */

package org.microg.gms.ui

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log

class HideMicrogSyncReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent?) {
        when (intent?.action) {
            Intent.ACTION_BOOT_COMPLETED,
            Intent.ACTION_MY_PACKAGE_REPLACED -> {
                Log.d(TAG, "Scheduling automatic Hide from apps sync for ${intent.action}")
                HideMicrogSettings.enqueueAutomaticSync(context)
            }
        }
    }

    companion object {
        private const val TAG = "HideMicrogSyncReceiver"
    }
}
Loading