Repo Created

This commit is contained in:
Fr4nz D13trich 2025-11-15 17:44:12 +01:00
parent eb305e2886
commit a8c22c65db
4784 changed files with 329907 additions and 2 deletions

View file

@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
dependencies {
api project(':play-services-location')
implementation project(':play-services-base-core')
}
android {
namespace "org.microg.gms.location.base"
compileSdkVersion androidCompileSdk
buildToolsVersion "$androidBuildVersionTools"
buildFeatures {
buildConfig = true
}
defaultConfig {
versionName version
minSdkVersion androidMinSdk
targetSdkVersion androidTargetSdk
def onlineSourcesString = ""
if (localProperties.get("location.online-sources", "") != "") {
onlineSourcesString = localProperties.get("location.online-sources", "[]")
} else if (localProperties.get("ichnaea.endpoint", "") != "") {
onlineSourcesString = "[{\"id\": \"default\", \"url\": \"${localProperties.get("ichnaea.endpoint", "")}\"},{\"id\": \"custom\", \"import\": true}]"
} else {
onlineSourcesString = "[{\"id\": \"beacondb\", \"name\": \"BeaconDB\", \"url\": \"https://api.beacondb.net/\", \"host\": \"beacondb.net\", \"terms\": \"https://beacondb.net/privacy/\", \"import\": true, \"allowContribute\": true},{\"id\": \"custom\", \"import\": true}]"
}
buildConfigField "java.util.List<org.microg.gms.location.network.OnlineSource>", "ONLINE_SOURCES", "org.microg.gms.location.network.OnlineSourceKt.parseOnlineSources(\"${onlineSourcesString.replaceAll("\"", "\\\\\"")}\")"
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = 1.8
}
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ SPDX-FileCopyrightText: 2023 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<manifest />

View file

@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import org.microg.gms.location.base.BuildConfig
import org.microg.gms.settings.SettingsContract
private const val PATH_GEOLOCATE = "/v1/geolocate"
private const val PATH_GEOLOCATE_QUERY = "/v1/geolocate?"
private const val PATH_GEOSUBMIT = "/v2/geosubmit"
private const val PATH_GEOSUBMIT_QUERY = "/v2/geosubmit?"
private const val PATH_QUERY_ONLY = "/?"
class LocationSettings(private val context: Context) {
private fun <T> getSettings(vararg projection: String, f: (Cursor) -> T): T =
SettingsContract.getSettings(context, SettingsContract.Location.getContentUri(context), projection, f)
private fun setSettings(v: ContentValues.() -> Unit) = SettingsContract.setSettings(context, SettingsContract.Location.getContentUri(context), v)
var wifiIchnaea: Boolean
get() = getSettings(SettingsContract.Location.WIFI_ICHNAEA) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.WIFI_ICHNAEA, value) }
var wifiMoving: Boolean
get() = getSettings(SettingsContract.Location.WIFI_MOVING) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.WIFI_MOVING, value) }
var wifiLearning: Boolean
get() = getSettings(SettingsContract.Location.WIFI_LEARNING) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.WIFI_LEARNING, value) }
var wifiCaching: Boolean
get() = getSettings(SettingsContract.Location.WIFI_CACHING) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.WIFI_CACHING, value) }
var cellIchnaea: Boolean
get() = getSettings(SettingsContract.Location.CELL_ICHNAEA) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.CELL_ICHNAEA, value) }
var cellLearning: Boolean
get() = getSettings(SettingsContract.Location.CELL_LEARNING) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.CELL_LEARNING, value) }
var cellCaching: Boolean
get() = getSettings(SettingsContract.Location.CELL_CACHING) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.CELL_CACHING, value) }
var geocoderNominatim: Boolean
get() = getSettings(SettingsContract.Location.GEOCODER_NOMINATIM) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.GEOCODER_NOMINATIM, value) }
var customEndpoint: String?
get() {
try {
var endpoint = getSettings(SettingsContract.Location.ICHNAEA_ENDPOINT) { c -> c.getString(0) }
// This is only temporary as users might have already broken configuration.
// Usually this would be corrected before storing it in settings, see below.
if (endpoint.endsWith(PATH_GEOLOCATE)) {
endpoint = endpoint.substring(0, endpoint.length - PATH_GEOLOCATE.length + 1)
} else if (endpoint.contains(PATH_GEOLOCATE_QUERY)) {
endpoint = endpoint.replace(PATH_GEOLOCATE_QUERY, PATH_QUERY_ONLY)
} else if (endpoint.endsWith(PATH_GEOSUBMIT)) {
endpoint = endpoint.substring(0, endpoint.length - PATH_GEOSUBMIT.length + 1)
} else if (endpoint.contains(PATH_GEOSUBMIT_QUERY)) {
endpoint = endpoint.replace(PATH_GEOSUBMIT_QUERY, PATH_QUERY_ONLY)
}
return endpoint
} catch (e: Exception) {
return null
}
}
set(value) {
val endpoint = if (value == null) {
null
} else if (value.endsWith(PATH_GEOLOCATE)) {
value.substring(0, value.length - PATH_GEOLOCATE.length + 1)
} else if (value.contains(PATH_GEOLOCATE_QUERY)) {
value.replace(PATH_GEOLOCATE_QUERY, PATH_QUERY_ONLY)
} else if (value.endsWith(PATH_GEOSUBMIT)) {
value.substring(0, value.length - PATH_GEOSUBMIT.length + 1)
} else if (value.contains(PATH_GEOSUBMIT_QUERY)) {
value.replace(PATH_GEOSUBMIT_QUERY, PATH_QUERY_ONLY)
} else {
value
}
setSettings { put(SettingsContract.Location.ICHNAEA_ENDPOINT, endpoint) }
}
var onlineSourceId: String?
get() = getSettings(SettingsContract.Location.ONLINE_SOURCE) { c -> c.getString(0) }
set(value) = setSettings { put(SettingsContract.Location.ONLINE_SOURCE, value) }
var ichnaeaContribute: Boolean
get() = getSettings(SettingsContract.Location.ICHNAEA_CONTRIBUTE) { c -> c.getInt(0) != 0 }
set(value) = setSettings { put(SettingsContract.Location.ICHNAEA_CONTRIBUTE, value) }
}

View file

@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.os.SystemClock
import android.text.format.DateUtils
import androidx.core.location.LocationCompat
const val ACTION_NETWORK_LOCATION_SERVICE = "org.microg.gms.location.network.ACTION_NETWORK_LOCATION_SERVICE"
const val EXTRA_LOCATION = "location"
const val EXTRA_ELAPSED_REALTIME = "elapsed_realtime"
const val EXTRA_PENDING_INTENT = "pending_intent"
const val EXTRA_ENABLE = "enable"
const val EXTRA_INTERVAL_MILLIS = "interval"
const val EXTRA_FORCE_NOW = "force_now"
const val EXTRA_LOW_POWER = "low_power"
const val EXTRA_WORK_SOURCE = "work_source"
const val EXTRA_BYPASS = "bypass"
const val ACTION_CONFIGURATION_REQUIRED = "org.microg.gms.location.network.ACTION_CONFIGURATION_REQUIRED"
const val EXTRA_CONFIGURATION = "config"
const val CONFIGURATION_FIELD_ONLINE_SOURCE = "online_source"
const val ACTION_NETWORK_IMPORT_EXPORT = "org.microg.gms.location.network.ACTION_NETWORK_IMPORT_EXPORT"
const val EXTRA_DIRECTION = "direction"
const val DIRECTION_IMPORT = "import"
const val DIRECTION_EXPORT = "export"
const val EXTRA_NAME = "name"
const val NAME_WIFI = "wifi"
const val NAME_CELL = "cell"
const val EXTRA_URI = "uri"
const val EXTRA_MESSENGER = "messenger"
const val EXTRA_REPLY_WHAT = "what"
val Location.elapsedMillis: Long
get() = LocationCompat.getElapsedRealtimeMillis(this)
fun Long.formatRealtime(): CharSequence = if (this <= 0) "n/a" else DateUtils.getRelativeTimeSpanString((this - SystemClock.elapsedRealtime()) + System.currentTimeMillis(), System.currentTimeMillis(), 0)
fun Long.formatDuration(): CharSequence {
if (this == 0L) return "0ms"
if (this > 315360000000L /* ten years */) return "\u221e"
val interval = listOf(1000, 60, 60, 24, Long.MAX_VALUE)
val intervalName = listOf("ms", "s", "m", "h", "d")
var ret = ""
var rem = this
for (i in 0 until interval.size) {
val mod = rem % interval[i]
if (mod != 0L) {
ret = "$mod${intervalName[i]}$ret"
}
rem /= interval[i]
if (mod == 0L && rem == 1L) {
ret = "${interval[i]}${intervalName[i]}$ret"
break
} else if (rem == 0L) {
break
}
}
return ret
}
private var hasNetworkLocationServiceBuiltInFlag: Boolean? = null
fun Context.hasNetworkLocationServiceBuiltIn(): Boolean {
var flag = hasNetworkLocationServiceBuiltInFlag
if (flag == null) {
try {
val serviceIntent = Intent().apply {
action = ACTION_NETWORK_LOCATION_SERVICE
setPackage(packageName)
}
val services = packageManager?.queryIntentServices(serviceIntent, PackageManager.MATCH_DEFAULT_ONLY)
flag = services?.isNotEmpty() ?: false
hasNetworkLocationServiceBuiltInFlag = flag
return flag
} catch (e: Exception) {
hasNetworkLocationServiceBuiltInFlag = false
return false
}
} else {
return flag
}
}

View file

@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network
import android.net.Uri
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import org.microg.gms.location.LocationSettings
import org.microg.gms.location.base.BuildConfig
fun parseOnlineSources(string: String): List<OnlineSource> = JSONArray(string).let { array ->
(0 until array.length()).map { parseOnlineSource(array.getJSONObject(it)) }.also { Log.d("Location", "parseOnlineSources: ${it.joinToString()}") }
}
fun parseOnlineSource(json: JSONObject): OnlineSource {
val id = json.getString("id")
val url = json.optString("url").takeIf { it.isNotBlank() }
val host = json.optString("host").takeIf { it.isNotBlank() } ?: runCatching { Uri.parse(url).host }.getOrNull()
val name = json.optString("name").takeIf { it.isNotBlank() } ?: host
return OnlineSource(
id = id,
name = name,
url = url,
host = host,
terms = json.optString("terms").takeIf { it.isNotBlank() }?.let { runCatching { Uri.parse(it) }.getOrNull() },
suggested = json.optBoolean("suggested", false),
import = json.optBoolean("import", false),
allowContribute = json.optBoolean("allowContribute", false),
)
}
data class OnlineSource(
val id: String,
val name: String? = null,
val url: String? = null,
val host: String? = null,
val terms: Uri? = null,
/**
* Show suggested flag
*/
val suggested: Boolean = false,
/**
* If set, automatically import from custom URL if host matches (is the same domain suffix)
*/
val import: Boolean = false,
val allowContribute: Boolean = false,
) {
companion object {
/**
* Entry to allow configuring a custom URL
*/
val ID_CUSTOM = "custom"
/**
* Legacy compatibility
*/
val ID_DEFAULT = "default"
val ALL: List<OnlineSource> = BuildConfig.ONLINE_SOURCES
}
}
val LocationSettings.onlineSource: OnlineSource?
get() {
val id = onlineSourceId
if (id != null) {
val source = OnlineSource.ALL.firstOrNull { it.id == id }
if (source != null) return source
}
val endpoint = customEndpoint
if (endpoint != null) {
val endpointHostSuffix = runCatching { "." + Uri.parse(endpoint).host }.getOrNull()
if (endpointHostSuffix != null) {
for (source in OnlineSource.ALL) {
if (source.import && endpointHostSuffix.endsWith("." + source.host)) {
return source
}
}
}
val customSource = OnlineSource.ALL.firstOrNull { it.id == OnlineSource.ID_CUSTOM }
if (customSource != null && customSource.import) {
return customSource
}
}
if (OnlineSource.ALL.size == 1) return OnlineSource.ALL.single()
return OnlineSource.ALL.firstOrNull { it.id == OnlineSource.ID_DEFAULT }
}
val LocationSettings.effectiveEndpoint: String?
get() {
val source = onlineSource ?: return null
if (source.id == OnlineSource.ID_CUSTOM) return customEndpoint
return source.url
}

View file

@ -0,0 +1,84 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
dependencies {
api project(':play-services-location')
implementation project(':play-services-base-core')
implementation project(':play-services-location-core-base')
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "androidx.navigation:navigation-fragment-ktx:$navigationVersion"
implementation "androidx.navigation:navigation-ui-ktx:$navigationVersion"
implementation "androidx.preference:preference-ktx:$preferenceVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion"
implementation "com.android.volley:volley:$volleyVersion"
compileOnly project(':play-services-maps')
}
android {
namespace "org.microg.gms.location.core"
compileSdkVersion androidCompileSdk
buildToolsVersion "$androidBuildVersionTools"
buildFeatures {
buildConfig = true
}
defaultConfig {
versionName version
minSdkVersion androidMinSdk
targetSdkVersion androidTargetSdk
buildConfigField "String", "FORCE_SHOW_BACKGROUND_PERMISSION", "\"\""
buildConfigField "boolean", "SHOW_NOTIFICATION_WHEN_NOT_PERMITTED", "false"
}
lintOptions {
disable 'MissingTranslation', 'GetLocales'
}
flavorDimensions = ['target']
productFlavors {
"default" {
dimension 'target'
}
"huawei" {
dimension 'target'
buildConfigField "String", "FORCE_SHOW_BACKGROUND_PERMISSION", "\"com.huawei.permission.sec.MDM.v2\""
buildConfigField "boolean", "SHOW_NOTIFICATION_WHEN_NOT_PERMITTED", "true"
}
"huaweilh" {
dimension 'target'
buildConfigField "String", "FORCE_SHOW_BACKGROUND_PERMISSION", "\"com.huawei.permission.sec.MDM.v2\""
buildConfigField "boolean", "SHOW_NOTIFICATION_WHEN_NOT_PERMITTED", "true"
}
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
huawei.java.srcDirs += 'src/huawei/kotlin'
huaweilh.java.srcDirs += huawei.java.srcDirs
huaweilh.res.srcDirs += huawei.res.srcDirs
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = 1.8
}
}

View file

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
dependencies {
api project(':play-services-location')
compileOnly project(':play-services-location-core-system-api')
implementation project(':play-services-base-core')
implementation project(':play-services-location-core-base')
implementation "androidx.appcompat:appcompat:$appcompatVersion"
implementation "androidx.core:core-ktx:$coreVersion"
implementation "androidx.lifecycle:lifecycle-service:$lifecycleVersion"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion"
implementation "com.android.volley:volley:$volleyVersion"
implementation 'org.microg:address-formatter:0.3.1'
}
android {
namespace "org.microg.gms.location.provider"
compileSdkVersion androidCompileSdk
buildToolsVersion "$androidBuildVersionTools"
buildFeatures {
buildConfig = true
}
defaultConfig {
versionName version
minSdkVersion androidMinSdk
targetSdkVersion androidTargetSdk
buildConfigField "String", "ICHNAEA_USER_AGENT", "\"microG/${version}\""
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
kotlinOptions {
jvmTarget = 1.8
}
}

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2023 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-permission
android:name="android.permission.LOCATION_HARDWARE"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.INSTALL_LOCATION_PROVIDER"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.NETWORK_SCAN"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<application>
<uses-library android:name="com.android.location.provider" />
<service
android:name="org.microg.gms.location.network.NetworkLocationService"
android:exported="false">
<intent-filter>
<action android:name="org.microg.gms.location.network.ACTION_NETWORK_LOCATION_SERVICE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="org.microg.gms.location.network.ACTION_NETWORK_IMPORT_EXPORT" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<provider
android:name="org.microg.gms.location.network.DatabaseExportFileProvider"
android:authorities="${applicationId}.microg.location.export"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/location_exported_files" />
</provider>
<service
android:name="org.microg.gms.location.provider.NetworkLocationProviderService"
android:exported="true"
android:permission="android.permission.WRITE_SECURE_SETTINGS">
<intent-filter>
<action android:name="com.android.location.service.v2.NetworkLocationProvider" />
<action android:name="com.android.location.service.v3.NetworkLocationProvider" />
</intent-filter>
<meta-data
android:name="serviceVersion"
android:value="2" />
</service>
<service
android:name="org.microg.gms.location.provider.FusedLocationProviderService"
android:exported="true"
android:permission="android.permission.WRITE_SECURE_SETTINGS">
<intent-filter>
<action android:name="com.android.location.service.FusedLocationProvider" />
</intent-filter>
<meta-data
android:name="serviceVersion"
android:value="2" />
</service>
<service
android:name="org.microg.gms.location.provider.GeocodeProviderService"
android:exported="true"
android:permission="android.permission.WRITE_SECURE_SETTINGS">
<intent-filter>
<action android:name="com.android.location.service.GeocodeProvider" />
<action android:name="com.google.android.location.GeocodeProvider" />
</intent-filter>
<meta-data
android:name="serviceVersion"
android:value="2" />
</service>
</application>
</manifest>

View file

@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network
import android.net.Uri
import androidx.core.content.FileProvider
class DatabaseExportFileProvider : FileProvider() {
override fun getType(uri: Uri): String? {
try {
if (uri.lastPathSegment?.startsWith("cell-") == true) {
return "application/vnd.microg.location.cell+csv+gzip"
}
if (uri.lastPathSegment?.startsWith("wifi-") == true) {
return "application/vnd.microg.location.wifi+csv+gzip"
}
} catch (ignored: Exception) {}
return super.getType(uri)
}
}

View file

@ -0,0 +1,674 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network
import android.content.ContentValues
import android.content.Context
import android.database.Cursor
import android.database.DatabaseUtils
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import android.database.sqlite.SQLiteOpenHelper
import android.location.Location
import android.net.Uri
import android.os.ParcelFileDescriptor
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.content.contentValuesOf
import androidx.core.database.getDoubleOrNull
import androidx.core.database.getStringOrNull
import androidx.core.os.BundleCompat
import org.microg.gms.location.NAME_CELL
import org.microg.gms.location.NAME_WIFI
import org.microg.gms.location.network.cell.CellDetails
import org.microg.gms.location.network.cell.isValid
import org.microg.gms.location.network.wifi.WifiDetails
import org.microg.gms.location.network.wifi.isRequestable
import org.microg.gms.location.network.wifi.macBytes
import org.microg.gms.utils.toHexString
import java.io.File
import java.io.FileNotFoundException
import java.io.PrintWriter
import java.util.*
import java.util.zip.GZIPInputStream
import java.util.zip.GZIPOutputStream
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
private const val CURRENT_VERSION = 8
internal class LocationDatabase(private val context: Context) : SQLiteOpenHelper(context, "geocache.db", null, CURRENT_VERSION) {
private data class Migration(val apply: String?, val revert: String?, val allowApplyFailure: Boolean, val allowRevertFailure: Boolean)
private val migrations: Map<Int, List<Migration>>
init {
val migrations = mutableMapOf<Int, MutableList<Migration>>()
fun declare(version: Int, apply: String, revert: String? = null, allowFailure: Boolean = false, allowApplyFailure: Boolean = allowFailure, allowRevertFailure: Boolean = allowFailure) {
if (!migrations.containsKey(version))
migrations[version] = arrayListOf()
migrations[version]!!.add(Migration(apply, revert, allowApplyFailure, allowRevertFailure))
}
declare(3, "CREATE TABLE $TABLE_CELLS($FIELD_MCC INTEGER NOT NULL, $FIELD_MNC INTEGER NOT NULL, $FIELD_TYPE INTEGER NOT NULL, $FIELD_LAC_TAC INTEGER NOT NULL, $FIELD_CID INTEGER NOT NULL, $FIELD_PSC INTEGER NOT NULL, $FIELD_LATITUDE REAL NOT NULL, $FIELD_LONGITUDE REAL NOT NULL, $FIELD_ACCURACY REAL NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_PRECISION REAL NOT NULL);")
declare(3, "CREATE TABLE $TABLE_CELLS_PRE($FIELD_MCC INTEGER NOT NULL, $FIELD_MNC INTEGER NOT NULL, $FIELD_TIME INTEGER NOT NULL);")
declare(3, "CREATE TABLE $TABLE_WIFIS($FIELD_MAC BLOB, $FIELD_LATITUDE REAL NOT NULL, $FIELD_LONGITUDE REAL NOT NULL, $FIELD_ACCURACY REAL NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_PRECISION REAL NOT NULL);")
declare(3, "CREATE TABLE $TABLE_CELLS_LEARN($FIELD_MCC INTEGER NOT NULL, $FIELD_MNC INTEGER NOT NULL, $FIELD_TYPE INTEGER NOT NULL, $FIELD_LAC_TAC INTEGER NOT NULL, $FIELD_CID INTEGER NOT NULL, $FIELD_PSC INTEGER NOT NULL, $FIELD_LATITUDE_HIGH REAL NOT NULL, $FIELD_LATITUDE_LOW REAL NOT NULL, $FIELD_LONGITUDE_HIGH REAL NOT NULL, $FIELD_LONGITUDE_LOW REAL NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_BAD_TIME INTEGER);")
declare(3, "CREATE TABLE $TABLE_WIFI_LEARN($FIELD_MAC BLOB, $FIELD_LATITUDE_HIGH REAL NOT NULL, $FIELD_LATITUDE_LOW REAL NOT NULL, $FIELD_LONGITUDE_HIGH REAL NOT NULL, $FIELD_LONGITUDE_LOW REAL NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_BAD_TIME INTEGER);")
declare(3, "CREATE UNIQUE INDEX ${TABLE_CELLS}_index ON $TABLE_CELLS($FIELD_MCC, $FIELD_MNC, $FIELD_TYPE, $FIELD_LAC_TAC, $FIELD_CID, $FIELD_PSC);")
declare(3, "CREATE UNIQUE INDEX ${TABLE_CELLS_PRE}_index ON $TABLE_CELLS_PRE($FIELD_MCC, $FIELD_MNC);")
declare(3, "CREATE UNIQUE INDEX ${TABLE_WIFIS}_index ON $TABLE_WIFIS($FIELD_MAC);")
declare(3, "CREATE UNIQUE INDEX ${TABLE_CELLS_LEARN}_index ON $TABLE_CELLS_LEARN($FIELD_MCC, $FIELD_MNC, $FIELD_TYPE, $FIELD_LAC_TAC, $FIELD_CID, $FIELD_PSC);")
declare(3, "CREATE UNIQUE INDEX ${TABLE_WIFI_LEARN}_index ON $TABLE_WIFI_LEARN($FIELD_MAC);")
declare(3, "CREATE INDEX ${TABLE_CELLS}_time_index ON $TABLE_CELLS($FIELD_TIME);")
declare(3, "CREATE INDEX ${TABLE_CELLS_PRE}_time_index ON $TABLE_CELLS_PRE($FIELD_TIME);")
declare(3, "CREATE INDEX ${TABLE_WIFIS}_time_index ON $TABLE_WIFIS($FIELD_TIME);")
declare(3, "CREATE INDEX ${TABLE_CELLS_LEARN}_time_index ON $TABLE_CELLS_LEARN($FIELD_TIME);")
declare(3, "CREATE INDEX ${TABLE_WIFI_LEARN}_time_index ON $TABLE_WIFI_LEARN($FIELD_TIME);")
declare(3, "DROP TABLE IF EXISTS $TABLE_WIFI_SCANS;", allowFailure = true)
declare(4, "ALTER TABLE $TABLE_CELLS_LEARN ADD COLUMN $FIELD_LEARN_RECORD_COUNT INTEGER;")
declare(4, "ALTER TABLE $TABLE_WIFI_LEARN ADD COLUMN $FIELD_LEARN_RECORD_COUNT INTEGER;")
declare(5, "ALTER TABLE $TABLE_CELLS ADD COLUMN $FIELD_ALTITUDE REAL;")
declare(5, "ALTER TABLE $TABLE_CELLS ADD COLUMN $FIELD_ALTITUDE_ACCURACY REAL;")
declare(5, "ALTER TABLE $TABLE_WIFIS ADD COLUMN $FIELD_ALTITUDE REAL;")
declare(5, "ALTER TABLE $TABLE_WIFIS ADD COLUMN $FIELD_ALTITUDE_ACCURACY REAL;")
declare(6, "ALTER TABLE $TABLE_CELLS_LEARN ADD COLUMN $FIELD_ALTITUDE_HIGH REAL;")
declare(6, "ALTER TABLE $TABLE_CELLS_LEARN ADD COLUMN $FIELD_ALTITUDE_LOW REAL;")
declare(6, "ALTER TABLE $TABLE_WIFI_LEARN ADD COLUMN $FIELD_ALTITUDE_HIGH REAL;")
declare(6, "ALTER TABLE $TABLE_WIFI_LEARN ADD COLUMN $FIELD_ALTITUDE_LOW REAL;")
declare(8, "DELETE FROM $TABLE_WIFIS WHERE $FIELD_ACCURACY = 0.0 AND ($FIELD_LATITUDE != 0.0 OR $FIELD_LONGITUDE != 0.0);", allowRevertFailure = true)
declare(8, "DELETE FROM $TABLE_CELLS WHERE $FIELD_ACCURACY = 0.0 AND ($FIELD_LATITUDE != 0.0 OR $FIELD_LONGITUDE != 0.0);", allowRevertFailure = true)
declare(8, "UPDATE $TABLE_CELLS_LEARN SET $FIELD_ALTITUDE_LOW = NULL WHERE $FIELD_ALTITUDE_LOW = 0.0;", allowRevertFailure = true)
declare(8, "UPDATE $TABLE_CELLS_LEARN SET $FIELD_ALTITUDE_HIGH = NULL WHERE $FIELD_ALTITUDE_HIGH = 0.0;", allowRevertFailure = true)
declare(8, "UPDATE $TABLE_WIFI_LEARN SET $FIELD_ALTITUDE_LOW = NULL WHERE $FIELD_ALTITUDE_LOW = 0.0;", allowRevertFailure = true)
declare(8, "UPDATE $TABLE_WIFI_LEARN SET $FIELD_ALTITUDE_HIGH = NULL WHERE $FIELD_ALTITUDE_HIGH = 0.0;", allowRevertFailure = true)
this.migrations = migrations
}
private fun migrate(db: SQLiteDatabase, oldVersion: Int, newVersion: Int, allowFailure: Boolean = false) {
var currentVersion = oldVersion
while (currentVersion < newVersion) {
val nextVersion = currentVersion + 1
val migrations = this.migrations[nextVersion].orEmpty()
for (migration in migrations) {
if (migration.apply == null && !migration.allowApplyFailure && !allowFailure)
throw SQLiteException("Incomplete migration from $currentVersion to $nextVersion")
try {
db.execSQL(migration.apply)
Log.d(TAG, "Applied migration from version $currentVersion to $nextVersion: ${migration.apply}")
} catch (e: Exception) {
Log.w(TAG, "Error while applying migration from version $currentVersion to $nextVersion: ${migration.apply}", e)
if (!migration.allowApplyFailure && !allowFailure)
throw e
}
}
currentVersion = nextVersion
}
while (currentVersion > newVersion) {
val nextVersion = currentVersion - 1
val migrations = this.migrations[currentVersion].orEmpty()
for (migration in migrations.asReversed()) {
if (migration.revert == null && !migration.allowRevertFailure && !allowFailure)
throw SQLiteException("Incomplete migration from $currentVersion to $nextVersion")
try {
db.execSQL(migration.revert)
Log.d(TAG, "Reverted migration from version $currentVersion to $nextVersion: ${migration.revert}")
} catch (e: Exception) {
Log.w(TAG, "Error while reverting migration from version $currentVersion to $nextVersion: ${migration.revert}", e)
if (!migration.allowRevertFailure && !allowFailure)
throw e
}
}
currentVersion = nextVersion
}
Log.i(TAG, "Migrated from $oldVersion to $newVersion")
}
private fun SQLiteDatabase.query(table: String, columns: Array<String>, selection: String? = null, selectionArgs: Array<String>? = null) =
query(table, columns, selection, selectionArgs, null, null, null)
fun getCellLocation(cell: CellDetails, allowLearned: Boolean = true): Location? {
var cursor = readableDatabase.query(TABLE_CELLS, FIELDS_CACHE_LOCATION, CELLS_SELECTION, getCellSelectionArgs(cell))
val cellLocation = cursor.getSingleLocation(MAX_CACHE_AGE)
if (allowLearned) {
cursor = readableDatabase.query(TABLE_CELLS_LEARN, FIELDS_MID_LOCATION_GET_LEARN, CELLS_SELECTION, getCellSelectionArgs(cell))
try {
if (cursor.moveToNext()) {
val badTime = cursor.getLong(8)
val time = cursor.getLong(7)
if (badTime < time - LEARN_BAD_CUTOFF) {
cursor.getCellMidLocation()?.let {
if (cellLocation == null || cellLocation == NEGATIVE_CACHE_ENTRY || cellLocation.precision < it.precision) return it
}
}
}
} finally {
cursor.close()
}
}
if (cellLocation != null) return cellLocation
cursor = readableDatabase.query(TABLE_CELLS_PRE, arrayOf(FIELD_TIME), CELLS_PRE_SELECTION, getCellPreSelectionArgs(cell))
try {
if (cursor.moveToNext()) {
if (cursor.getLong(1) > System.currentTimeMillis() - MAX_CACHE_AGE) {
return NEGATIVE_CACHE_ENTRY
}
}
} finally {
cursor.close()
}
return null
}
fun getWifiLocation(wifi: WifiDetails, allowLearned: Boolean = true): Location? {
var cursor = readableDatabase.query(TABLE_WIFIS, FIELDS_CACHE_LOCATION, getWifiSelection(wifi))
val wifiLocation = cursor.getSingleLocation(MAX_CACHE_AGE)
if (allowLearned) {
cursor = readableDatabase.query(TABLE_WIFI_LEARN, FIELDS_MID_LOCATION_GET_LEARN, getWifiSelection(wifi))
try {
if (cursor.moveToNext()) {
val badTime = cursor.getLong(8)
val time = cursor.getLong(7)
if (badTime < time - LEARN_BAD_CUTOFF) {
cursor.getWifiMidLocation()?.let {
if (wifiLocation == null || wifiLocation == NEGATIVE_CACHE_ENTRY || wifiLocation.precision < it.precision) return it
}
}
}
} finally {
cursor.close()
}
}
return wifiLocation
}
fun putCellLocation(cell: CellDetails, location: Location) {
if (!cell.isValid) return
val cv = contentValuesOf(
FIELD_MCC to cell.mcc,
FIELD_MNC to cell.mnc,
FIELD_LAC_TAC to (cell.lac ?: cell.tac ?: 0),
FIELD_TYPE to cell.type.ordinal,
FIELD_CID to cell.cid,
FIELD_PSC to (cell.pscOrPci ?: 0)
).apply { putLocation(location) }
writableDatabase.insertWithOnConflict(TABLE_CELLS, null, cv, SQLiteDatabase.CONFLICT_REPLACE)
}
fun putWifiLocation(wifi: WifiDetails, location: Location) {
if (!wifi.isRequestable) return
val cv = contentValuesOf(
FIELD_MAC to wifi.macBytes
).apply { putLocation(location) }
writableDatabase.insertWithOnConflict(TABLE_WIFIS, null, cv, SQLiteDatabase.CONFLICT_REPLACE)
}
fun learnCellLocation(cell: CellDetails, location: Location, import: Boolean = false): Boolean {
if (!cell.isValid) return false
val cursor = readableDatabase.query(TABLE_CELLS_LEARN, FIELDS_MID_LOCATION, CELLS_SELECTION, getCellSelectionArgs(cell))
var exists = false
var isBad = false
var midLocation: Location? = null
try {
if (cursor.moveToNext()) {
midLocation = cursor.getMidLocation()
exists = midLocation != null
isBad = midLocation?.let { it.distanceTo(location) > LEARN_BAD_SIZE_CELL } == true
}
} finally {
cursor.close()
}
if (exists && isBad) {
val values = ContentValues().apply { putLearnLocation(location, badTime = location.time, import = import) }
writableDatabase.update(TABLE_CELLS_LEARN, values, CELLS_SELECTION, getCellSelectionArgs(cell))
} else if (!exists) {
val values = contentValuesOf(
FIELD_MCC to cell.mcc,
FIELD_MNC to cell.mnc,
FIELD_LAC_TAC to (cell.lac ?: cell.tac ?: 0),
FIELD_TYPE to cell.type.ordinal,
FIELD_CID to cell.cid,
FIELD_PSC to (cell.pscOrPci ?: 0),
).apply { putLearnLocation(location, badTime = 0) }
writableDatabase.insertWithOnConflict(TABLE_CELLS_LEARN, null, values, SQLiteDatabase.CONFLICT_REPLACE)
} else {
val values = ContentValues().apply { putLearnLocation(location, midLocation) }
writableDatabase.update(TABLE_CELLS_LEARN, values, CELLS_SELECTION, getCellSelectionArgs(cell))
}
return true
}
fun learnWifiLocation(wifi: WifiDetails, location: Location, import: Boolean = false): Boolean {
if (!wifi.isRequestable) return false
val cursor = readableDatabase.query(TABLE_WIFI_LEARN, FIELDS_MID_LOCATION, getWifiSelection(wifi))
var exists = false
var isBad = false
var midLocation: Location? = null
try {
if (cursor.moveToNext()) {
midLocation = cursor.getMidLocation()
exists = midLocation != null
isBad = midLocation?.let { it.distanceTo(location) > LEARN_BAD_SIZE_WIFI } == true
}
} finally {
cursor.close()
}
if (exists && isBad) {
val values = ContentValues().apply { putLearnLocation(location, badTime = location.time, import = import) }
writableDatabase.update(TABLE_WIFI_LEARN, values, getWifiSelection(wifi), null)
} else if (!exists) {
val values = contentValuesOf(
FIELD_MAC to wifi.macBytes
).apply { putLearnLocation(location, badTime = 0) }
writableDatabase.insertWithOnConflict(TABLE_WIFI_LEARN, null, values, SQLiteDatabase.CONFLICT_REPLACE)
} else {
val values = ContentValues().apply { putLearnLocation(location, midLocation) }
writableDatabase.update(TABLE_WIFI_LEARN, values, getWifiSelection(wifi), null)
}
return true
}
fun exportLearned(name: String): Uri? {
try {
val wifi = when (name) {
NAME_WIFI -> true
NAME_CELL -> false
else -> throw IllegalArgumentException()
}
val fieldNames = if (wifi) FIELDS_WIFI else FIELDS_CELL
val tableName = if (wifi) TABLE_WIFI_LEARN else TABLE_CELLS_LEARN
val midLocationGetter: (Cursor) -> Location? = if (wifi) Cursor::getWifiMidLocation else Cursor::getCellMidLocation
val exportDir = File(context.cacheDir, "location")
exportDir.mkdir()
val exportFile = File(exportDir, "$name-${UUID.randomUUID()}.csv.gz")
val output = GZIPOutputStream(exportFile.outputStream()).bufferedWriter()
output.write("${fieldNames.joinToString(",")},${FIELDS_EXPORT_DATA.joinToString(",")}\n")
val cursor = readableDatabase.query(tableName, FIELDS_MID_LOCATION + fieldNames)
val indices = fieldNames.map { cursor.getColumnIndexOrThrow(it) }
while (cursor.moveToNext()) {
val midLocation = midLocationGetter(cursor)
if (midLocation != null) {
output.write(indices.joinToString(",") { index ->
if (cursor.getType(index) == Cursor.FIELD_TYPE_BLOB) {
cursor.getBlob(index).toHexString()
} else {
cursor.getStringOrNull(index) ?: ""
}
})
output.write(",${midLocation.latitude},${midLocation.longitude},${if (midLocation.hasAltitude()) midLocation.altitude else ""}\n")
}
}
output.close()
return FileProvider.getUriForFile(context,"${context.packageName}.microg.location.export", exportFile)
} catch (e: Exception) {
Log.w(TAG, e)
}
return null
}
fun importLearned(fileUri: Uri): Int {
var counter = 0
try {
val type = context.contentResolver.getType(fileUri)
val gzip = if (type == null || type !in SUPPORTED_TYPES) {
if (fileUri.path == null) throw IllegalArgumentException("Unsupported file extension")
if (fileUri.path!!.endsWith(".gz")) {
true
} else if (fileUri.path!!.endsWith(".csv")) {
false
} else {
throw IllegalArgumentException("Unsupported file extension")
}
} else {
type.endsWith("gzip")
}
val desc = context.contentResolver.openFileDescriptor(fileUri, "r") ?: throw FileNotFoundException()
ParcelFileDescriptor.AutoCloseInputStream(desc).use { source ->
val input = (if (gzip) GZIPInputStream(source) else source).bufferedReader()
val headers = input.readLine().split(",")
val name = when {
headers.containsAll(FIELDS_WIFI.toList()) && headers.containsAll(FIELDS_EXPORT_DATA.toList()) -> NAME_WIFI
headers.containsAll(FIELDS_CELL.toList()) && headers.containsAll(FIELDS_EXPORT_DATA.toList()) -> NAME_CELL
else -> null
}
if (name != null) {
while (true) {
val line = input.readLine().split(",")
if (line.size != headers.size) break // End of file reached
val location = Location(PROVIDER_CACHE)
location.latitude = line[headers.indexOf(FIELD_LATITUDE)].toDoubleOrNull() ?: continue
location.longitude = line[headers.indexOf(FIELD_LONGITUDE)].toDoubleOrNull() ?: continue
line[headers.indexOf(FIELD_ALTITUDE)].toDoubleOrNull()?.let { location.altitude = it }
location.time = headers.indexOf(FIELD_TIME).takeIf { it != -1 }?.let { line[it] }?.toLongOrNull()?.takeIf { it > 0 } ?: System.currentTimeMillis()
if (name == NAME_WIFI) {
val wifi = WifiDetails(
macAddress = line[headers.indexOf(FIELD_MAC)]
)
if (learnWifiLocation(wifi, location)) counter++
} else {
val cell = CellDetails(
type = line[headers.indexOf(FIELD_TYPE)].let {
it.toIntOrNull()?.let { CellDetails.Companion.Type.entries[it] } ?:
runCatching { CellDetails.Companion.Type.valueOf(it) }.getOrNull()
} ?: continue,
mcc = line[headers.indexOf(FIELD_MCC)].toIntOrNull() ?: continue,
mnc = line[headers.indexOf(FIELD_MNC)].toIntOrNull() ?: continue,
lac = line[headers.indexOf(FIELD_LAC_TAC)].toIntOrNull(),
tac = line[headers.indexOf(FIELD_LAC_TAC)].toIntOrNull(),
cid = line[headers.indexOf(FIELD_CID)].toLongOrNull() ?: continue,
pscOrPci = line[headers.indexOf(FIELD_PSC)].toIntOrNull(),
)
if (learnCellLocation(cell, location)) counter++
}
}
}
}
} catch (e: Exception) {
Log.w(TAG, e)
}
return counter
}
override fun onCreate(db: SQLiteDatabase) {
migrate(db, 0, CURRENT_VERSION)
}
fun cleanup(db: SQLiteDatabase) {
db.delete(TABLE_CELLS, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_CACHE_AGE).toString()))
db.delete(TABLE_CELLS_PRE, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_CACHE_AGE).toString()))
db.delete(TABLE_WIFIS, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_CACHE_AGE).toString()))
db.delete(TABLE_CELLS_LEARN, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_LEARN_AGE).toString()))
db.delete(TABLE_WIFI_LEARN, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_LEARN_AGE).toString()))
}
override fun onOpen(db: SQLiteDatabase) {
super.onOpen(db)
if (!db.isReadOnly) {
cleanup(db)
}
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
migrate(db, oldVersion, newVersion)
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
migrate(db, oldVersion, newVersion)
}
fun dump(writer: PrintWriter) {
writer.println("Database: cells(cached)=${DatabaseUtils.queryNumEntries(readableDatabase, TABLE_CELLS)}, cells(learnt)=${DatabaseUtils.queryNumEntries(readableDatabase, TABLE_CELLS_LEARN)}, wifis(cached)=${DatabaseUtils.queryNumEntries(readableDatabase, TABLE_WIFIS)}, wifis(learnt)=${DatabaseUtils.queryNumEntries(readableDatabase, TABLE_WIFI_LEARN)}")
}
}
const val EXTRA_HIGH_LOCATION = "high"
const val EXTRA_LOW_LOCATION = "low"
const val EXTRA_RECORD_COUNT = "recs"
private const val MAX_CACHE_AGE = 1000L * 60 * 60 * 24 * 14 // 14 days
private const val MAX_LEARN_AGE = 1000L * 60 * 60 * 24 * 365 // 1 year
private const val TABLE_CELLS = "cells"
private const val TABLE_CELLS_PRE = "cells_pre"
private const val TABLE_WIFIS = "wifis"
private const val TABLE_WIFI_SCANS = "wifi_scans"
private const val TABLE_CELLS_LEARN = "cells_learn"
private const val TABLE_WIFI_LEARN = "wifis_learn"
private const val FIELD_MCC = "mcc"
private const val FIELD_MNC = "mnc"
private const val FIELD_TYPE = "type"
private const val FIELD_LAC_TAC = "lac"
private const val FIELD_CID = "cid"
private const val FIELD_PSC = "psc"
private const val FIELD_LATITUDE = "lat"
private const val FIELD_LONGITUDE = "lon"
private const val FIELD_ACCURACY = "acc"
private const val FIELD_ALTITUDE = "alt"
private const val FIELD_ALTITUDE_ACCURACY = "alt_acc"
private const val FIELD_TIME = "time"
private const val FIELD_PRECISION = "prec"
private const val FIELD_MAC = "mac"
private const val FIELD_SCAN_HASH = "hash"
private const val FIELD_LATITUDE_HIGH = "lath"
private const val FIELD_LATITUDE_LOW = "latl"
private const val FIELD_LONGITUDE_HIGH = "lonh"
private const val FIELD_LONGITUDE_LOW = "lonl"
private const val FIELD_ALTITUDE_HIGH = "alth"
private const val FIELD_ALTITUDE_LOW = "altl"
private const val FIELD_BAD_TIME = "btime"
private const val FIELD_LEARN_RECORD_COUNT = "recs"
private const val LEARN_BASE_ACCURACY_CELL = 10_000.0
private const val LEARN_BASE_VERTICAL_ACCURACY_CELL = 5_000.0
private const val LEARN_BASE_PRECISION_CELL = 0.5
private const val LEARN_ACCURACY_FACTOR_CELL = 0.002
private const val LEARN_VERTICAL_ACCURACY_FACTOR_CELL = 0.002
private const val LEARN_PRECISION_FACTOR_CELL = 0.01
private const val LEARN_BAD_SIZE_CELL = 10_000
private const val LEARN_BASE_ACCURACY_WIFI = 200.0
private const val LEARN_BASE_VERTICAL_ACCURACY_WIFI = 100.0
private const val LEARN_BASE_PRECISION_WIFI = 0.2
private const val LEARN_ACCURACY_FACTOR_WIFI = 0.02
private const val LEARN_VERTICAL_ACCURACY_FACTOR_WIFI = 0.02
private const val LEARN_PRECISION_FACTOR_WIFI = 0.1
private const val LEARN_BAD_SIZE_WIFI = 200
private const val LEARN_BAD_CUTOFF = 1000L * 60 * 60 * 24 * 14
private const val CELLS_SELECTION = "$FIELD_MCC = ? AND $FIELD_MNC = ? AND $FIELD_TYPE = ? AND $FIELD_LAC_TAC = ? AND $FIELD_CID = ? AND $FIELD_PSC = ?"
private const val CELLS_PRE_SELECTION = "$FIELD_MCC = ? AND $FIELD_MNC = ?"
private val FIELDS_CACHE_LOCATION = arrayOf(FIELD_LATITUDE, FIELD_LONGITUDE, FIELD_ACCURACY, FIELD_ALTITUDE, FIELD_ALTITUDE_ACCURACY, FIELD_TIME, FIELD_PRECISION)
private val FIELDS_MID_LOCATION = arrayOf(FIELD_LATITUDE_HIGH, FIELD_LATITUDE_LOW, FIELD_LONGITUDE_HIGH, FIELD_LONGITUDE_LOW, FIELD_ALTITUDE_HIGH, FIELD_ALTITUDE_LOW, FIELD_LEARN_RECORD_COUNT, FIELD_TIME)
private val FIELDS_MID_LOCATION_GET_LEARN = FIELDS_MID_LOCATION + FIELD_BAD_TIME
private val FIELDS_CELL = arrayOf(FIELD_MCC, FIELD_MNC, FIELD_TYPE, FIELD_LAC_TAC, FIELD_CID, FIELD_PSC)
private val FIELDS_WIFI = arrayOf(FIELD_MAC)
private val FIELDS_EXPORT_DATA = arrayOf(FIELD_LATITUDE, FIELD_LONGITUDE, FIELD_ALTITUDE)
private val SUPPORTED_TYPES = listOf(
"application/vnd.microg.location.cell+csv+gzip",
"application/vnd.microg.location.cell+csv",
"application/vnd.microg.location.wifi+csv+gzip",
"application/vnd.microg.location.wifi+csv",
"application/gzip",
"application/x-gzip",
"text/csv",
)
private fun getCellSelectionArgs(cell: CellDetails): Array<String> {
return arrayOf(
cell.mcc.toString(),
cell.mnc.toString(),
cell.type.ordinal.toString(),
(cell.lac ?: cell.tac ?: 0).toString(),
cell.cid.toString(),
(cell.pscOrPci ?: 0).toString(),
)
}
private fun getWifiSelection(wifi: WifiDetails): String {
return "$FIELD_MAC = x'${wifi.macBytes.toHexString()}'"
}
private fun getCellPreSelectionArgs(cell: CellDetails): Array<String> {
return arrayOf(
cell.mcc.toString(),
cell.mnc.toString()
)
}
private fun Cursor.getSingleLocation(maxAge: Long): Location? {
return try {
if (moveToNext()) {
getLocation(maxAge)
} else {
null
}
} finally {
close()
}
}
private fun Cursor.getLocation(maxAge: Long): Location? {
if (getLong(5) > System.currentTimeMillis() - maxAge) {
if (getDouble(2) == 0.0) return NEGATIVE_CACHE_ENTRY
return Location(PROVIDER_CACHE).apply {
latitude = getDouble(0)
longitude = getDouble(1)
accuracy = getDouble(2).toFloat()
getDoubleOrNull(3)?.let { altitude = it }
verticalAccuracy = getDoubleOrNull(4)?.toFloat()
time = getLong(5)
precision = getDouble(6)
}
}
return null
}
private fun Cursor.getMidLocation(
maxAge: Long = Long.MAX_VALUE,
baseAccuracy: Double = 0.0,
accuracyFactor: Double = 0.0,
baseVerticalAccuracy: Double = baseAccuracy,
verticalAccuracyFactor: Double = accuracyFactor,
basePrecision: Double = 0.0,
precisionFactor: Double = 0.0
): Location? {
if (maxAge == Long.MAX_VALUE || getLong(7) > System.currentTimeMillis() - maxAge) {
val high = Location(PROVIDER_CACHE).apply { latitude = getDouble(0); longitude = getDouble(2) }
if (!isNull(4)) high.altitude = getDouble(4)
val low = Location(PROVIDER_CACHE).apply { latitude = getDouble(1); longitude = getDouble(3) }
if (!isNull(5)) low.altitude = getDouble(5)
val count = getInt(6)
val computedAccuracy = baseAccuracy / (1 + (accuracyFactor * (count - 1).toDouble()))
val computedVerticalAccuracy = baseVerticalAccuracy / (1 + (verticalAccuracyFactor * (count - 1).toDouble()))
return Location(PROVIDER_CACHE).apply {
latitude = (high.latitude + low.latitude) / 2.0
longitude = (high.longitude + low.longitude) / 2.0
accuracy = max(high.distanceTo(low) / 2.0, computedAccuracy).toFloat()
if (high.hasAltitude() && low.hasAltitude()) {
altitude = (high.altitude + low.altitude) / 2.0
verticalAccuracy = max((abs(high.altitude - low.altitude) / 2.0), computedVerticalAccuracy).toFloat()
} else if (high.hasAltitude()) {
altitude = high.altitude
verticalAccuracy = computedVerticalAccuracy.toFloat()
} else if (low.hasAltitude()) {
altitude = low.altitude
verticalAccuracy = computedVerticalAccuracy.toFloat()
}
precision = basePrecision * (1 + (precisionFactor * (count - 1)))
highLocation = high
lowLocation = low
extras += EXTRA_RECORD_COUNT to count
}
}
return null
}
private fun Cursor.getWifiMidLocation() = getMidLocation(
MAX_LEARN_AGE,
baseAccuracy = LEARN_BASE_ACCURACY_WIFI,
accuracyFactor = LEARN_ACCURACY_FACTOR_WIFI,
baseVerticalAccuracy = LEARN_BASE_VERTICAL_ACCURACY_WIFI,
verticalAccuracyFactor = LEARN_VERTICAL_ACCURACY_FACTOR_WIFI,
basePrecision = LEARN_BASE_PRECISION_WIFI,
precisionFactor = LEARN_PRECISION_FACTOR_WIFI
)
private fun Cursor.getCellMidLocation() = getMidLocation(
MAX_LEARN_AGE,
baseAccuracy = LEARN_BASE_ACCURACY_CELL,
accuracyFactor = LEARN_ACCURACY_FACTOR_CELL,
baseVerticalAccuracy = LEARN_BASE_VERTICAL_ACCURACY_CELL,
verticalAccuracyFactor = LEARN_VERTICAL_ACCURACY_FACTOR_CELL,
basePrecision = LEARN_BASE_PRECISION_CELL,
precisionFactor = LEARN_PRECISION_FACTOR_CELL
)
private var Location.highLocation: Location?
get() = extras?.let { BundleCompat.getParcelable(it, EXTRA_HIGH_LOCATION, Location::class.java) }
set(value) { extras += EXTRA_HIGH_LOCATION to value }
private val Location.highLatitude: Double
get() = highLocation?.latitude ?: latitude
private val Location.highLongitude: Double
get() = highLocation?.longitude ?: longitude
private val Location.highAltitude: Double?
get() = highLocation?.takeIf { it.hasAltitude() }?.altitude ?: altitude.takeIf { hasAltitude() }
private var Location.lowLocation: Location?
get() = extras?.let { BundleCompat.getParcelable(it, EXTRA_LOW_LOCATION, Location::class.java) }
set(value) { extras += EXTRA_LOW_LOCATION to value }
private val Location.lowLatitude: Double
get() = lowLocation?.latitude ?: latitude
private val Location.lowLongitude: Double
get() = lowLocation?.longitude ?: longitude
private val Location.lowAltitude: Double?
get() = lowLocation?.takeIf { it.hasAltitude() }?.altitude ?: altitude.takeIf { hasAltitude() }
private var Location?.recordCount: Int
get() = this?.extras?.getInt(EXTRA_RECORD_COUNT, 0) ?: 0
set(value) { this?.extras += EXTRA_RECORD_COUNT to value }
private fun max(first: Double, second: Double?): Double {
if (second == null) return first
return max(first, second)
}
private fun max(first: Long, second: Long?): Long {
if (second == null) return first
return max(first, second)
}
private fun min(first: Double, second: Double?): Double {
if (second == null) return first
return min(first, second)
}
private fun ContentValues.putLocation(location: Location) {
if (location != NEGATIVE_CACHE_ENTRY) {
put(FIELD_LATITUDE, location.latitude)
put(FIELD_LONGITUDE, location.longitude)
put(FIELD_ACCURACY, location.accuracy)
put(FIELD_TIME, location.time)
put(FIELD_PRECISION, location.precision)
if (location.hasAltitude()) {
put(FIELD_ALTITUDE, location.altitude)
put(FIELD_ALTITUDE_ACCURACY, location.verticalAccuracy)
}
} else {
put(FIELD_LATITUDE, 0.0)
put(FIELD_LONGITUDE, 0.0)
put(FIELD_ACCURACY, 0.0)
put(FIELD_TIME, System.currentTimeMillis())
put(FIELD_PRECISION, 0.0)
}
}
private fun ContentValues.putLearnLocation(location: Location, previous: Location? = null, badTime: Long? = null, import: Boolean = false) {
if (location != NEGATIVE_CACHE_ENTRY) {
put(FIELD_LATITUDE_HIGH, max(location.latitude, previous?.highLatitude))
put(FIELD_LATITUDE_LOW, min(location.latitude, previous?.lowLatitude))
put(FIELD_LONGITUDE_HIGH, max(location.longitude, previous?.highLongitude))
put(FIELD_LONGITUDE_LOW, min(location.longitude, previous?.lowLongitude))
put(FIELD_TIME, max(location.time, previous?.time ?: 0))
if (location.hasAltitude()) {
put(FIELD_ALTITUDE_HIGH, max(location.altitude, previous?.highAltitude))
put(FIELD_ALTITUDE_LOW, min(location.altitude, previous?.lowAltitude))
}
put(FIELD_LEARN_RECORD_COUNT, previous.recordCount + 1)
if (badTime != null && !import) {
put(FIELD_BAD_TIME, max(badTime, previous?.time))
}
}
}

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network
interface NetworkDetails {
val timestamp: Long?
val signalStrength: Int?
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.location.Location
import android.os.SystemClock
import android.os.WorkSource
import org.microg.gms.location.EXTRA_LOCATION
import org.microg.gms.location.EXTRA_ELAPSED_REALTIME
class NetworkLocationRequest(
var pendingIntent: PendingIntent,
var intervalMillis: Long,
var lowPower: Boolean,
var bypass: Boolean,
var workSource: WorkSource
) {
var lastRealtime = 0L
private set
fun send(context: Context, location: Location) {
lastRealtime = SystemClock.elapsedRealtime()
pendingIntent.send(context, 0, Intent().apply {
putExtra(EXTRA_LOCATION, location)
putExtra(EXTRA_ELAPSED_REALTIME, lastRealtime)
})
}
}

View file

@ -0,0 +1,619 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.location.Location
import android.location.LocationManager
import android.net.Uri
import android.net.wifi.WifiManager
import android.os.*
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import androidx.annotation.GuardedBy
import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat
import androidx.core.os.bundleOf
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import org.microg.gms.location.*
import org.microg.gms.location.network.cell.CellDetails
import org.microg.gms.location.network.cell.CellDetailsCallback
import org.microg.gms.location.network.cell.CellDetailsSource
import org.microg.gms.location.network.ichnaea.IchnaeaServiceClient
import org.microg.gms.location.network.wifi.*
import java.io.FileDescriptor
import java.io.PrintWriter
import java.util.*
import kotlin.math.*
class NetworkLocationService : LifecycleService(), WifiDetailsCallback, CellDetailsCallback {
private lateinit var handlerThread: HandlerThread
private lateinit var handler: Handler
@GuardedBy("activeRequests")
private val activeRequests = HashSet<NetworkLocationRequest>()
private val highPowerScanRunnable = Runnable { this.scan(false) }
private val lowPowerScanRunnable = Runnable { this.scan(true) }
private var wifiDetailsSource: WifiDetailsSource? = null
private var cellDetailsSource: CellDetailsSource? = null
private val ichnaea by lazy { IchnaeaServiceClient(this) }
private val database by lazy { LocationDatabase(this) }
private val movingWifiHelper by lazy { MovingWifiHelper(this) }
private val settings by lazy { LocationSettings(this) }
private var lastHighPowerScanRealtime = 0L
private var lastLowPowerScanRealtime = 0L
private var highPowerIntervalMillis = Long.MAX_VALUE
private var lowPowerIntervalMillis = Long.MAX_VALUE
private var lastWifiDetailsRealtimeMillis = 0L
private var lastCellDetailsRealtimeMillis = 0L
private val locationLock = Any()
private var lastWifiLocation: Location? = null
private var lastCellLocation: Location? = null
private var lastLocation: Location? = null
private val passiveLocationListener by lazy { LocationListenerCompat { onNewPassiveLocation(it) } }
@GuardedBy("gpsLocationBuffer")
private val gpsLocationBuffer = LinkedList<Location>()
private var passiveListenerActive = false
private var currentLocalMovingWifi: WifiDetails? = null
private var lastLocalMovingWifiLocationCandidate: Location? = null
private val interval: Long
get() = min(highPowerIntervalMillis, lowPowerIntervalMillis)
override fun onCreate() {
super.onCreate()
handlerThread = HandlerThread(NetworkLocationService::class.java.simpleName)
handlerThread.start()
handler = Handler(handlerThread.looper)
wifiDetailsSource = WifiDetailsSource.create(this, this).apply { enable() }
cellDetailsSource = CellDetailsSource.create(this, this).apply { enable() }
if (settings.effectiveEndpoint == null && (settings.wifiIchnaea || settings.cellIchnaea)) {
sendBroadcast(Intent(ACTION_CONFIGURATION_REQUIRED).apply {
`package` = packageName
putExtra(EXTRA_CONFIGURATION, CONFIGURATION_FIELD_ONLINE_SOURCE)
})
}
}
private fun updatePassiveGpsListenerRegistration() {
try {
getSystemService<LocationManager>()?.let { locationManager ->
if ((settings.cellLearning || settings.wifiLearning) && (highPowerIntervalMillis != Long.MAX_VALUE)) {
if (!passiveListenerActive) {
LocationManagerCompat.requestLocationUpdates(
locationManager,
LocationManager.PASSIVE_PROVIDER,
LocationRequestCompat.Builder(LocationRequestCompat.PASSIVE_INTERVAL)
.setQuality(LocationRequestCompat.QUALITY_LOW_POWER)
.setMinUpdateIntervalMillis(GPS_PASSIVE_INTERVAL)
.build(),
passiveLocationListener,
handlerThread.looper
)
passiveListenerActive = true
}
} else {
if (passiveListenerActive) {
LocationManagerCompat.removeUpdates(locationManager, passiveLocationListener)
passiveListenerActive = false
}
}
}
} catch (e: SecurityException) {
Log.d(TAG, "GPS location retriever not initialized due to lack of permission")
} catch (e: Exception) {
Log.d(TAG, "GPS location retriever not initialized", e)
}
}
@SuppressLint("WrongConstant")
private fun scan(lowPower: Boolean) {
if (!lowPower) lastHighPowerScanRealtime = SystemClock.elapsedRealtime()
lastLowPowerScanRealtime = SystemClock.elapsedRealtime()
val currentLocalMovingWifi = currentLocalMovingWifi
val lastWifiScanIsSufficientlyNewForMoving = lastWifiDetailsRealtimeMillis > SystemClock.elapsedRealtime() - MAX_LOCAL_WIFI_SCAN_AGE_MS
val movingWifiWasProducingRecentResults = (lastLocalMovingWifiLocationCandidate?.elapsedMillis ?: 0L) > SystemClock.elapsedRealtime() - max(MAX_LOCAL_WIFI_AGE_MS, interval * 2)
val movingWifiLocationWasAccurate = (lastLocalMovingWifiLocationCandidate?.accuracy ?: Float.MAX_VALUE) <= MOVING_WIFI_HIGH_POWER_ACCURACY
if (currentLocalMovingWifi != null &&
movingWifiWasProducingRecentResults &&
lastWifiScanIsSufficientlyNewForMoving &&
(movingWifiLocationWasAccurate || lowPower) &&
getSystemService<WifiManager>()?.connectionInfo?.bssid == currentLocalMovingWifi.macAddress
) {
Log.d(TAG, "Skip network scan and use current local wifi instead. low=$lowPower accurate=$movingWifiLocationWasAccurate")
onWifiDetailsAvailable(listOf(currentLocalMovingWifi.copy(timestamp = System.currentTimeMillis())))
} else {
val workSource = synchronized(activeRequests) { activeRequests.minByOrNull { it.intervalMillis }?.workSource }
Log.d(TAG, "Start network scan for $workSource")
if (settings.wifiLearning || settings.wifiCaching || settings.wifiIchnaea) {
wifiDetailsSource?.startScan(workSource)
} else if (settings.wifiMoving) {
// No need to scan if only moving wifi enabled, instead simulate scan based on current connection info
val connectionInfo = getSystemService<WifiManager>()?.connectionInfo
if (SDK_INT >= 31 && connectionInfo != null) {
onWifiDetailsAvailable(listOf(connectionInfo.toWifiDetails()))
} else if (currentLocalMovingWifi != null && connectionInfo?.bssid == currentLocalMovingWifi.macAddress) {
onWifiDetailsAvailable(listOf(currentLocalMovingWifi.copy(timestamp = System.currentTimeMillis())))
} else {
// Can't simulate scan, so just scan
wifiDetailsSource?.startScan(workSource)
}
}
if (settings.cellLearning || settings.cellCaching || settings.cellIchnaea) {
cellDetailsSource?.startScan(workSource)
}
}
updateRequests()
}
private fun updateRequests(forceNow: Boolean = false, lowPower: Boolean = true) {
synchronized(activeRequests) {
lowPowerIntervalMillis = Long.MAX_VALUE
highPowerIntervalMillis = Long.MAX_VALUE
for (request in activeRequests) {
if (request.lowPower) lowPowerIntervalMillis = min(lowPowerIntervalMillis, request.intervalMillis)
else highPowerIntervalMillis = min(highPowerIntervalMillis, request.intervalMillis)
}
}
// Low power must be strictly less than high power
if (highPowerIntervalMillis <= lowPowerIntervalMillis) lowPowerIntervalMillis = Long.MAX_VALUE
val nextHighPowerRequestIn =
if (highPowerIntervalMillis == Long.MAX_VALUE) Long.MAX_VALUE else highPowerIntervalMillis - (SystemClock.elapsedRealtime() - lastHighPowerScanRealtime)
val nextLowPowerRequestIn =
if (lowPowerIntervalMillis == Long.MAX_VALUE) Long.MAX_VALUE else lowPowerIntervalMillis - (SystemClock.elapsedRealtime() - lastLowPowerScanRealtime)
handler.removeCallbacks(highPowerScanRunnable)
handler.removeCallbacks(lowPowerScanRunnable)
if ((forceNow && !lowPower) || nextHighPowerRequestIn <= 0) {
Log.d(TAG, "Schedule high-power scan now")
handler.post(highPowerScanRunnable)
} else if (forceNow || nextLowPowerRequestIn <= 0) {
Log.d(TAG, "Schedule low-power scan now")
handler.post(lowPowerScanRunnable)
} else {
// Reschedule next request
if (nextLowPowerRequestIn < nextHighPowerRequestIn) {
Log.d(TAG, "Schedule low-power scan in ${nextLowPowerRequestIn}ms")
handler.postDelayed(lowPowerScanRunnable, nextLowPowerRequestIn)
} else if (nextHighPowerRequestIn != Long.MAX_VALUE) {
Log.d(TAG, "Schedule high-power scan in ${nextHighPowerRequestIn}ms")
handler.postDelayed(highPowerScanRunnable, nextHighPowerRequestIn)
}
}
updatePassiveGpsListenerRegistration()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_NETWORK_LOCATION_SERVICE) {
handler.post {
val pendingIntent = intent.getParcelableExtra<PendingIntent>(EXTRA_PENDING_INTENT) ?: return@post
val enable = intent.getBooleanExtra(EXTRA_ENABLE, false)
if (enable) {
val intervalMillis = intent.getLongExtra(EXTRA_INTERVAL_MILLIS, -1L)
if (intervalMillis < 0) return@post
var forceNow = intent.getBooleanExtra(EXTRA_FORCE_NOW, false)
val lowPower = intent.getBooleanExtra(EXTRA_LOW_POWER, true)
val bypass = intent.getBooleanExtra(EXTRA_BYPASS, false)
val workSource = intent.getParcelableExtra(EXTRA_WORK_SOURCE) ?: WorkSource()
synchronized(activeRequests) {
if (activeRequests.any { it.pendingIntent == pendingIntent }) {
forceNow = false
activeRequests.removeAll { it.pendingIntent == pendingIntent }
}
activeRequests.add(NetworkLocationRequest(pendingIntent, intervalMillis, lowPower, bypass, workSource))
}
handler.post { updateRequests(forceNow, lowPower) }
} else {
synchronized(activeRequests) {
activeRequests.removeAll { it.pendingIntent == pendingIntent }
}
handler.post { updateRequests() }
}
}
} else if (intent?.action == ACTION_NETWORK_IMPORT_EXPORT) {
handler.post {
val callback = intent.getParcelableExtra<Messenger>(EXTRA_MESSENGER)
val replyWhat = intent.getIntExtra(EXTRA_REPLY_WHAT, 0)
when (intent.getStringExtra(EXTRA_DIRECTION)) {
DIRECTION_EXPORT -> {
val name = intent.getStringExtra(EXTRA_NAME)
val uri = name?.let { database.exportLearned(it) }
callback?.send(Message.obtain().apply {
what = replyWhat
data = bundleOf(
EXTRA_DIRECTION to DIRECTION_EXPORT,
EXTRA_NAME to name,
EXTRA_URI to uri,
)
})
}
DIRECTION_IMPORT -> {
val uri = intent.getParcelableExtra<Uri>(EXTRA_URI)
val counter = uri?.let { database.importLearned(it) } ?: 0
callback?.send(Message.obtain().apply {
what = replyWhat
arg1 = counter
data = bundleOf(
EXTRA_DIRECTION to DIRECTION_IMPORT,
EXTRA_URI to uri,
)
})
}
}
}
}
super.onStartCommand(intent, flags, startId)
return START_STICKY
}
override fun onDestroy() {
handlerThread.stop()
wifiDetailsSource?.disable()
wifiDetailsSource = null
cellDetailsSource?.disable()
cellDetailsSource = null
super.onDestroy()
}
fun Location.mayTakeAltitude(location: Location?) {
if (location != null && !hasAltitude() && location.hasAltitude()) {
altitude = location.altitude
verticalAccuracy = location.verticalAccuracy
}
}
suspend fun queryWifiLocation(wifis: List<WifiDetails>): Location? {
var candidate: Location? = queryCurrentLocalMovingWifiLocation()
if ((candidate?.accuracy ?: Float.MAX_VALUE) <= 50f) return candidate
val databaseCandidate = queryWifiLocationFromDatabase(wifis)
if (databaseCandidate != null && (candidate == null || databaseCandidate.precision > candidate.precision)) {
databaseCandidate.mayTakeAltitude(candidate)
candidate = databaseCandidate
}
if ((candidate?.accuracy ?: Float.MAX_VALUE) <= 50f && (candidate?.precision ?: 0.0) > 1.0) return candidate
val ichnaeaCandidate = queryIchnaeaWifiLocation(wifis, minimumPrecision = (candidate?.precision ?: 0.0))
if (ichnaeaCandidate != null && ichnaeaCandidate.accuracy >= (candidate?.accuracy ?: 0f)) {
ichnaeaCandidate.mayTakeAltitude(candidate)
candidate = ichnaeaCandidate
}
return candidate
}
private suspend fun queryCurrentLocalMovingWifiLocation(): Location? {
var candidate: Location? = null
val currentLocalMovingWifi = currentLocalMovingWifi
if (currentLocalMovingWifi != null && settings.wifiMoving) {
try {
withTimeout(5000L) {
lastLocalMovingWifiLocationCandidate = movingWifiHelper.retrieveMovingLocation(currentLocalMovingWifi)
}
candidate = lastLocalMovingWifiLocationCandidate
} catch (e: Exception) {
lastLocalMovingWifiLocationCandidate = null
Log.w(TAG, "Failed retrieving location for current moving wifi ${currentLocalMovingWifi.ssid}", e)
}
}
return candidate
}
private suspend fun queryWifiLocationFromDatabase(wifis: List<WifiDetails>): Location? =
queryLocationFromRetriever(wifis, 1000.0) { database.getWifiLocation(it, settings.wifiLearning) }
private suspend fun queryCellLocationFromDatabase(cells: List<CellDetails>): Location? =
queryLocationFromRetriever(cells, 50000.0) { it.location ?: database.getCellLocation(it, settings.cellLearning) }
private val NetworkDetails.signalStrengthBounded: Int
get() = (signalStrength ?: -100).coerceIn(-100, -10)
private val NetworkDetails.ageBounded: Long
get() = (System.currentTimeMillis() - (timestamp ?: 0)).coerceIn(0, 60000)
private val NetworkDetails.weight: Double
get() = min(1.0, sqrt(2000.0 / ageBounded)) / signalStrengthBounded.toDouble().pow(2)
private fun <T: NetworkDetails> queryLocationFromRetriever(data: List<T>, maxClusterDistance: Double = 0.0, retriever: (T) -> Location?): Location? {
val locations = data.mapNotNull { detail -> retriever(detail)?.takeIf { it != NEGATIVE_CACHE_ENTRY }?.let { detail to it } }
if (locations.isNotEmpty()) {
val clusters = locations.map { mutableListOf(it) }
for (cellLocation in locations) {
for (cluster in clusters) {
if (cluster.first() == cellLocation) continue;
if (cluster.first().second.distanceTo(cellLocation.second) < max(cluster.first().second.accuracy * 2.0, maxClusterDistance)) {
cluster.add(cellLocation)
}
}
}
val cluster = clusters.maxBy { it.sumOf { it.second.precision } }
return Location(PROVIDER_CACHE).apply {
latitude = cluster.weightedAverage { it.second.latitude to it.first.weight }
longitude = cluster.weightedAverage { it.second.longitude to it.first.weight }
accuracy = min(
cluster.map { it.second.distanceTo(this) + it.second.accuracy }.average().toFloat() / cluster.size,
cluster.minOf { it.second.accuracy }
)
val altitudeCluster = cluster.filter { it.second.hasAltitude() }.takeIf { it.isNotEmpty() }
if (altitudeCluster != null) {
altitude = altitudeCluster.weightedAverage { it.second.altitude to it.first.weight }
verticalAccuracy = min(
altitudeCluster.map { abs(altitude - it.second.altitude) + (it.second.verticalAccuracy ?: it.second.accuracy) }.average().toFloat() / cluster.size,
altitudeCluster.minOf { it.second.verticalAccuracy ?: it.second.accuracy }
)
}
precision = cluster.sumOf { it.second.precision }
time = System.currentTimeMillis()
}
}
return null
}
private suspend fun queryIchnaeaWifiLocation(wifis: List<WifiDetails>, minimumPrecision: Double = 0.0): Location? {
if (settings.wifiIchnaea && wifis.size >= 3 && wifis.size / IchnaeaServiceClient.WIFI_BASE_PRECISION_COUNT >= minimumPrecision) {
try {
val ichnaeaCandidate = ichnaea.retrieveMultiWifiLocation(wifis) { wifi, location ->
if (settings.wifiCaching) database.putWifiLocation(wifi, location)
}!!
ichnaeaCandidate.time = System.currentTimeMillis()
return ichnaeaCandidate
} catch (e: Exception) {
Log.w(TAG, "Failed retrieving location for ${wifis.size} wifi networks", e)
}
}
return null
}
private fun <T> List<T>.weightedAverage(f: (T) -> Pair<Double, Double>): Double {
val valuesAndWeights = map { f(it) }
return valuesAndWeights.sumOf { it.first * it.second } / valuesAndWeights.sumOf { it.second }
}
override fun onWifiDetailsAvailable(wifis: List<WifiDetails>) {
if (wifis.isEmpty()) return
val scanResultTimestamp = min(wifis.maxOf { it.timestamp ?: Long.MAX_VALUE }, System.currentTimeMillis())
val scanResultRealtimeMillis = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - scanResultTimestamp)
@Suppress("DEPRECATION")
currentLocalMovingWifi = getSystemService<WifiManager>()?.connectionInfo
?.let { wifiInfo -> wifis.filter { it.macAddress == wifiInfo.bssid && it.isMoving } }
?.filter { movingWifiHelper.isLocallyRetrievable(it) }
?.singleOrNull()
val requestableWifis = wifis.filter(WifiDetails::isRequestable)
if (requestableWifis.isEmpty() && currentLocalMovingWifi == null) return
updateWifiLocation(requestableWifis, scanResultRealtimeMillis, scanResultTimestamp)
}
private fun updateWifiLocation(requestableWifis: List<WifiDetails>, scanResultRealtimeMillis: Long = 0, scanResultTimestamp: Long = 0) {
if (settings.wifiLearning) {
for (wifi in requestableWifis.filter { it.timestamp != null }) {
val wifiElapsedMillis = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - wifi.timestamp!!)
getGpsLocation(wifiElapsedMillis)?.let {
database.learnWifiLocation(wifi, it)
}
}
}
if (scanResultRealtimeMillis < lastWifiDetailsRealtimeMillis + interval / 2 && lastWifiDetailsRealtimeMillis != 0L && scanResultRealtimeMillis != 0L) {
Log.d(TAG, "Ignoring wifi details, similar age as last ($scanResultRealtimeMillis < $lastWifiDetailsRealtimeMillis + $interval / 2)")
return
}
val previousLastRealtimeMillis = lastWifiDetailsRealtimeMillis
if (scanResultRealtimeMillis != 0L) lastWifiDetailsRealtimeMillis = scanResultRealtimeMillis
lifecycleScope.launch {
val location = queryWifiLocation(requestableWifis)
if (location == null) {
lastWifiDetailsRealtimeMillis = previousLastRealtimeMillis
return@launch
}
if (scanResultTimestamp != 0L) location.time = max(scanResultTimestamp, location.time)
if (scanResultRealtimeMillis != 0L) location.elapsedRealtimeNanos = max(location.elapsedRealtimeNanos, scanResultRealtimeMillis * 1_000_000L)
synchronized(locationLock) {
lastWifiLocation = location
}
sendLocationUpdate()
}
}
override fun onWifiSourceFailed() {
// Wifi source failed, create a new one
wifiDetailsSource?.disable()
wifiDetailsSource = WifiDetailsSource.create(this, this).apply { enable() }
}
private suspend fun queryCellLocation(cells: List<CellDetails>): Location? {
val candidate = queryCellLocationFromDatabase(cells)
if ((candidate?.precision ?: 0.0) > 1.0) return candidate
val cellsToUpdate = cells.filter { it.location == null && database.getCellLocation(it, settings.cellLearning) == null }
for (cell in cellsToUpdate) {
queryIchnaeaCellLocation(cell)
}
// Try again after fetching records from internet
return queryCellLocationFromDatabase(cells)
}
private suspend fun queryIchnaeaCellLocation(cell: CellDetails): Location? {
if (settings.cellIchnaea) {
try {
val ichnaeaCandidate = ichnaea.retrieveSingleCellLocation(cell) { cell, location ->
if (settings.cellCaching) database.putCellLocation(cell, location)
} ?: NEGATIVE_CACHE_ENTRY
if (settings.cellCaching) {
if (ichnaeaCandidate == NEGATIVE_CACHE_ENTRY) {
database.putCellLocation(cell, NEGATIVE_CACHE_ENTRY)
return null
} else {
ichnaeaCandidate.time = System.currentTimeMillis()
database.putCellLocation(cell, ichnaeaCandidate)
return ichnaeaCandidate
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed retrieving location for cell network", e)
}
}
return null
}
override fun onCellDetailsAvailable(cells: List<CellDetails>) {
if (settings.cellLearning) {
for (cell in cells.filter { it.timestamp != null && it.location == null }) {
val cellElapsedMillis = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - cell.timestamp!!)
getGpsLocation(cellElapsedMillis)?.let {
database.learnCellLocation(cell, it)
}
}
}
val scanResultTimestamp = min(cells.maxOf { it.timestamp ?: Long.MAX_VALUE }, System.currentTimeMillis())
val scanResultRealtimeMillis = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - scanResultTimestamp)
if (scanResultRealtimeMillis < lastCellDetailsRealtimeMillis + interval / 2 && lastCellDetailsRealtimeMillis != 0L) {
Log.d(TAG, "Ignoring cell details, similar age as last ($scanResultRealtimeMillis < $lastCellDetailsRealtimeMillis + $interval / 2)")
return
}
val previousLastRealtimeMillis = lastWifiDetailsRealtimeMillis
lastCellDetailsRealtimeMillis = scanResultRealtimeMillis
lifecycleScope.launch {
val location = queryCellLocation(cells)
if (location == null) {
lastCellDetailsRealtimeMillis = previousLastRealtimeMillis
return@launch
}
if (scanResultTimestamp != 0L) location.time = max(scanResultTimestamp, location.time)
if (scanResultRealtimeMillis != 0L) location.elapsedRealtimeNanos = max(location.elapsedRealtimeNanos, scanResultRealtimeMillis * 1_000_000L)
synchronized(locationLock) {
lastCellLocation = location
}
sendLocationUpdate()
}
}
private fun sendLocationUpdate(now: Boolean = false) {
fun cliffLocations(old: Location?, new: Location?): Location? {
// We move from wifi towards cell with accuracy
if (old == null) return new
if (new == null) return old
val diff = new.elapsedMillis - old.elapsedMillis
if (diff < LOCATION_TIME_CLIFF_START_MS) return old
if (diff > LOCATION_TIME_CLIFF_END_MS) return new
val pct = (diff - LOCATION_TIME_CLIFF_START_MS).toDouble() / (LOCATION_TIME_CLIFF_END_MS - LOCATION_TIME_CLIFF_START_MS).toDouble()
return Location(old).apply {
provider = "cliff"
latitude = old.latitude * (1.0-pct) + new.latitude * pct
longitude = old.longitude * (1.0-pct) + new.longitude * pct
accuracy = (old.accuracy * (1.0-pct) + new.accuracy * pct).toFloat()
altitude = old.altitude * (1.0-pct) + new.altitude * pct
time = (old.time.toDouble() * (1.0-pct) + new.time.toDouble() * pct).toLong()
elapsedRealtimeNanos = (old.elapsedRealtimeNanos.toDouble() * (1.0-pct) + new.elapsedRealtimeNanos.toDouble() * pct).toLong()
}
}
val location = synchronized(locationLock) {
if (lastCellLocation == null && lastWifiLocation == null) return
when {
// Only non-null
lastCellLocation == null -> lastWifiLocation
lastWifiLocation == null -> lastCellLocation
// Consider cliff end
lastCellLocation!!.elapsedMillis > lastWifiLocation!!.elapsedMillis + LOCATION_TIME_CLIFF_END_MS -> lastCellLocation
lastWifiLocation!!.elapsedMillis > lastCellLocation!!.elapsedMillis + LOCATION_TIME_CLIFF_START_MS -> lastWifiLocation
// Wifi out of cell range with higher precision
lastCellLocation!!.precision > lastWifiLocation!!.precision && lastWifiLocation!!.distanceTo(lastCellLocation!!) > 2 * lastCellLocation!!.accuracy -> lastCellLocation
// Consider cliff start
lastCellLocation!!.elapsedMillis > lastWifiLocation!!.elapsedMillis + LOCATION_TIME_CLIFF_START_MS -> cliffLocations(lastWifiLocation, lastCellLocation)
else -> lastWifiLocation
}
} ?: return
if (location == lastLocation) return
if (lastLocation == lastWifiLocation && lastLocation.let { it != null && location.accuracy > it.accuracy } && !now) {
Log.d(TAG, "Debounce inaccurate location update")
handler.postDelayed({
sendLocationUpdate(true)
}, DEBOUNCE_DELAY_MS)
return
}
lastLocation = location
synchronized(activeRequests) {
for (request in activeRequests.toList()) {
try {
request.send(this@NetworkLocationService, location)
} catch (e: Exception) {
Log.w(TAG, "Pending intent error $request")
activeRequests.remove(request)
}
}
}
}
private fun onNewPassiveLocation(location: Location) {
if (location.provider != LocationManager.GPS_PROVIDER || location.accuracy > GPS_PASSIVE_MIN_ACCURACY) return
synchronized(gpsLocationBuffer) {
if (gpsLocationBuffer.isNotEmpty() && gpsLocationBuffer.last.elapsedMillis < SystemClock.elapsedRealtime() - GPS_BUFFER_SIZE * GPS_PASSIVE_INTERVAL) {
gpsLocationBuffer.clear()
} else if (gpsLocationBuffer.size >= GPS_BUFFER_SIZE) {
gpsLocationBuffer.remove()
}
gpsLocationBuffer.offer(location)
}
}
private fun getGpsLocation(elapsedMillis: Long): Location? {
if (elapsedMillis + GPS_BUFFER_SIZE * GPS_PASSIVE_INTERVAL < SystemClock.elapsedRealtime()) return null
synchronized(gpsLocationBuffer) {
if (gpsLocationBuffer.isEmpty()) return null
for (location in gpsLocationBuffer.descendingIterator()) {
if (location.elapsedMillis in (elapsedMillis - GPS_PASSIVE_INTERVAL)..(elapsedMillis + GPS_PASSIVE_INTERVAL)) return location
if (location.elapsedMillis < elapsedMillis) return null
}
}
return null
}
override fun dump(fd: FileDescriptor?, writer: PrintWriter, args: Array<out String>?) {
writer.println("Last scan elapsed realtime: high-power: ${lastHighPowerScanRealtime.formatRealtime()}, low-power: ${lastLowPowerScanRealtime.formatRealtime()}")
writer.println("Last scan result time: wifi: ${lastWifiDetailsRealtimeMillis.formatRealtime()}, cells: ${lastCellDetailsRealtimeMillis.formatRealtime()}")
writer.println("Interval: high-power: ${highPowerIntervalMillis.formatDuration()}, low-power: ${lowPowerIntervalMillis.formatDuration()}")
writer.println("Last wifi location: $lastWifiLocation${if (lastWifiLocation == lastLocation) " (active)" else ""}")
writer.println("Last cell location: $lastCellLocation${if (lastCellLocation == lastLocation) " (active)" else ""}")
writer.println("Wifi settings: ichnaea=${settings.wifiIchnaea} moving=${settings.wifiMoving} learn=${settings.wifiLearning}")
writer.println("Cell settings: ichnaea=${settings.cellIchnaea} learn=${settings.cellLearning}")
writer.println("Ichnaea settings: source=${settings.onlineSource?.id} endpoint=${settings.effectiveEndpoint} contribute=${settings.ichnaeaContribute}")
ichnaea.dump(writer)
writer.println("GPS location buffer size=${gpsLocationBuffer.size} first=${gpsLocationBuffer.firstOrNull()?.elapsedMillis?.formatRealtime()} last=${gpsLocationBuffer.lastOrNull()?.elapsedMillis?.formatRealtime()}")
database.dump(writer)
synchronized(activeRequests) {
if (activeRequests.isNotEmpty()) {
writer.println("Active requests:")
for (request in activeRequests) {
writer.println("- ${request.workSource} ${request.intervalMillis.formatDuration()} (low power: ${request.lowPower}, bypass: ${request.bypass}) reported ${request.lastRealtime.formatRealtime()}")
}
}
}
}
companion object {
const val GPS_BUFFER_SIZE = 60
const val GPS_PASSIVE_INTERVAL = 1000L
const val GPS_PASSIVE_MIN_ACCURACY = 25f
const val LOCATION_TIME_CLIFF_START_MS = 30000L
const val LOCATION_TIME_CLIFF_END_MS = 60000L
const val DEBOUNCE_DELAY_MS = 5000L
const val MAX_WIFI_SCAN_CACHE_AGE = 1000L * 60 * 60 * 24 // 1 day
const val MAX_LOCAL_WIFI_AGE_MS = 60_000_000L // 1 minute
const val MAX_LOCAL_WIFI_SCAN_AGE_MS = 600_000_000L // 10 minutes
const val MOVING_WIFI_HIGH_POWER_ACCURACY = 100f
}
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.cell
import android.location.Location
import org.microg.gms.location.network.NetworkDetails
data class CellDetails(
val type: Type,
val mcc: Int? = null,
val mnc: Int? = null,
val lac: Int? = null,
val tac: Int? = null,
val cid: Long? = null,
val sid: Int? = null,
val nid: Int? = null,
val bsid: Int? = null,
val pscOrPci: Int? = null,
override val timestamp: Long? = null,
override val signalStrength: Int? = null,
val location: Location? = null
) : NetworkDetails {
companion object {
enum class Type {
CDMA, GSM, WCDMA, LTE, TDSCDMA, NR
}
}
}

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.cell
interface CellDetailsCallback {
fun onCellDetailsAvailable(cells: List<CellDetails>)
}

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.cell
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build.VERSION.SDK_INT
import android.os.WorkSource
import android.telephony.CellInfo
import android.telephony.TelephonyManager
import android.util.Log
import androidx.core.content.getSystemService
private const val TAG = "CellDetailsSource"
class CellDetailsSource(private val context: Context, private val callback: CellDetailsCallback) {
fun enable() = Unit
fun disable() = Unit
@SuppressLint("MissingPermission")
fun startScan(workSource: WorkSource?) {
val telephonyManager = context.getSystemService<TelephonyManager>() ?: return
if (SDK_INT >= 29) {
try {
telephonyManager.requestCellInfoUpdate(context.mainExecutor, object : TelephonyManager.CellInfoCallback() {
override fun onCellInfo(cells: MutableList<CellInfo>) {
val details = cells.map(CellInfo::toCellDetails).map { it.repair(context) }.filter(CellDetails::isValid)
if (details.isNotEmpty()) callback.onCellDetailsAvailable(details)
}
})
} catch (e: SecurityException) {
// It may trigger a SecurityException if the ACCESS_FINE_LOCATION permission isn't granted
Log.w(TAG, "requestCellInfoUpdate in startScan failed", e)
}
return
} else if (SDK_INT >= 17) {
val allCellInfo: List<CellInfo>? = try {
telephonyManager.allCellInfo
} catch (e: SecurityException) {
// It may trigger a SecurityException if the ACCESS_FINE_LOCATION permission isn't granted
Log.w(TAG, "allCellInfo in startScan failed", e)
null
}
if (allCellInfo != null) {
val details = allCellInfo.map(CellInfo::toCellDetails).map { it.repair(context) }.filter(CellDetails::isValid)
if (details.isNotEmpty()) {
callback.onCellDetailsAvailable(details)
return
}
}
}
val networkOperator = telephonyManager.networkOperator
if (networkOperator != null && networkOperator.length > 4) {
val mcc = networkOperator.substring(0, 3).toIntOrNull()
val mnc = networkOperator.substring(3).toIntOrNull()
val detail: CellDetails? = try {
telephonyManager.cellLocation?.toCellDetails(mcc, mnc)
} catch (e: SecurityException) {
// It may trigger a SecurityException if the ACCESS_FINE_LOCATION permission isn't granted
Log.w(TAG, "cellLocation in startScan failed", e)
null
}
if (detail?.isValid == true) callback.onCellDetailsAvailable(listOf(detail))
}
}
companion object {
fun create(context: Context, callback: CellDetailsCallback): CellDetailsSource = CellDetailsSource(context, callback)
}
}

View file

@ -0,0 +1,159 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.cell
import android.content.Context
import android.location.Location
import android.os.Build.VERSION.SDK_INT
import android.os.SystemClock
import android.telephony.CellIdentity
import android.telephony.CellIdentityCdma
import android.telephony.CellIdentityGsm
import android.telephony.CellIdentityLte
import android.telephony.CellIdentityNr
import android.telephony.CellIdentityTdscdma
import android.telephony.CellIdentityWcdma
import android.telephony.CellInfo
import android.telephony.CellInfoCdma
import android.telephony.CellInfoGsm
import android.telephony.CellInfoLte
import android.telephony.CellInfoTdscdma
import android.telephony.CellInfoWcdma
import android.telephony.CellLocation
import android.telephony.TelephonyManager
import android.telephony.cdma.CdmaCellLocation
import android.telephony.gsm.GsmCellLocation
import androidx.annotation.RequiresApi
import androidx.core.content.getSystemService
private fun locationFromCdma(latitude: Int, longitude: Int) = if (latitude == Int.MAX_VALUE || longitude == Int.MAX_VALUE) null else Location("cdma").also {
it.latitude = latitude.toDouble() / 14400.0
it.longitude = longitude.toDouble() / 14400.0
it.accuracy = 30000f
}
private fun CdmaCellLocation.toCellDetails(timestamp: Long? = null) = CellDetails(
type = CellDetails.Companion.Type.CDMA,
sid = systemId,
nid = networkId,
bsid = baseStationId,
location = locationFromCdma(baseStationLatitude, baseStationLongitude),
timestamp = timestamp
)
private fun GsmCellLocation.toCellDetails(mcc: Int? = null, mnc: Int? = null, timestamp: Long? = null) = CellDetails(
type = CellDetails.Companion.Type.GSM,
mcc = mcc,
mnc = mnc,
lac = lac.takeIf { it != Int.MAX_VALUE && it != -1 },
cid = cid.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong(),
pscOrPci = psc.takeIf { it != Int.MAX_VALUE && it != -1 },
timestamp = timestamp
)
internal fun CellLocation.toCellDetails(mcc: Int? = null, mnc: Int? = null, timestamp: Long? = null) = when (this) {
is CdmaCellLocation -> toCellDetails(timestamp)
is GsmCellLocation -> toCellDetails(mcc, mnc, timestamp)
else -> throw IllegalArgumentException("Unknown CellLocation type")
}
private fun CellIdentityCdma.toCellDetails() = CellDetails(
type = CellDetails.Companion.Type.CDMA,
sid = systemId,
nid = networkId,
bsid = basestationId,
location = locationFromCdma(latitude, longitude)
)
private fun CellIdentityGsm.toCellDetails() = CellDetails(
type = CellDetails.Companion.Type.GSM,
mcc = if (SDK_INT >= 28) mccString?.toIntOrNull() else mcc.takeIf { it != Int.MAX_VALUE && it != -1 },
mnc = if (SDK_INT >= 28) mncString?.toIntOrNull() else mnc.takeIf { it != Int.MAX_VALUE && it != -1 },
lac = lac.takeIf { it != Int.MAX_VALUE && it != -1 },
cid = cid.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong()
)
private fun CellIdentityWcdma.toCellDetails() = CellDetails(
type = CellDetails.Companion.Type.WCDMA,
mcc = if (SDK_INT >= 28) mccString?.toIntOrNull() else mcc.takeIf { it != Int.MAX_VALUE && it != -1 },
mnc = if (SDK_INT >= 28) mncString?.toIntOrNull() else mnc.takeIf { it != Int.MAX_VALUE && it != -1 },
lac = lac.takeIf { it != Int.MAX_VALUE && it != -1 },
cid = cid.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong(),
pscOrPci = psc.takeIf { it != Int.MAX_VALUE && it != -1 }
)
private fun CellIdentityLte.toCellDetails() = CellDetails(
type = CellDetails.Companion.Type.LTE,
mcc = if (SDK_INT >= 28) mccString?.toIntOrNull() else mcc.takeIf { it != Int.MAX_VALUE && it != -1 },
mnc = if (SDK_INT >= 28) mncString?.toIntOrNull() else mnc.takeIf { it != Int.MAX_VALUE && it != -1 },
tac = tac.takeIf { it != Int.MAX_VALUE && it != -1 },
cid = ci.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong(),
pscOrPci = pci.takeIf { it != Int.MAX_VALUE && it != -1 }
)
@RequiresApi(28)
private fun CellIdentityTdscdma.toCellDetails() = CellDetails(
type = CellDetails.Companion.Type.TDSCDMA,
mcc = mccString?.toIntOrNull(),
mnc = mncString?.toIntOrNull(),
lac = lac.takeIf { it != Int.MAX_VALUE && it != -1 },
cid = cid.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong()
)
@RequiresApi(29)
private fun CellIdentityNr.toCellDetails() = CellDetails(
type = CellDetails.Companion.Type.NR,
mcc = mccString?.toIntOrNull(),
mnc = mncString?.toIntOrNull(),
tac = tac.takeIf { it != Int.MAX_VALUE && it != -1 },
cid = nci.takeIf { it != Long.MAX_VALUE && it != -1L }
)
@RequiresApi(28)
internal fun CellIdentity.toCellDetails() = when {
this is CellIdentityCdma -> toCellDetails()
this is CellIdentityGsm -> toCellDetails()
this is CellIdentityWcdma -> toCellDetails()
this is CellIdentityLte -> toCellDetails()
this is CellIdentityTdscdma -> toCellDetails()
SDK_INT >= 29 && this is CellIdentityNr -> toCellDetails()
else -> throw IllegalArgumentException("Unknown CellIdentity type")
}
private val CellInfo.epochTimestamp: Long
@RequiresApi(17)
get() = if (SDK_INT >= 30) System.currentTimeMillis() - (SystemClock.elapsedRealtime() - timestampMillis)
else System.currentTimeMillis() - (SystemClock.elapsedRealtimeNanos() - timeStamp)
@RequiresApi(17)
internal fun CellInfo.toCellDetails() = when {
this is CellInfoCdma -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
this is CellInfoGsm -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
SDK_INT >= 18 && this is CellInfoWcdma -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
this is CellInfoLte -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
SDK_INT >= 29 && this is CellInfoTdscdma -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
SDK_INT >= 30 -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
else -> throw IllegalArgumentException("Unknown CellInfo type")
}
/**
* Fix a few known issues in Android's parsing of MNCs
*/
internal fun CellDetails.repair(context: Context): CellDetails {
if (type == CellDetails.Companion.Type.CDMA) return this
val networkOperator = context.getSystemService<TelephonyManager>()?.networkOperator ?: return this
if (networkOperator.length < 5) return this
val networkOperatorMnc = networkOperator.substring(3).toInt()
if (networkOperator[3] == '0' && mnc == null || networkOperator.length == 5 && mnc == networkOperatorMnc * 10 + 15)
return copy(mnc = networkOperatorMnc)
return this
}
val CellDetails.isValid: Boolean
get() = when (type) {
CellDetails.Companion.Type.CDMA -> sid != null && nid != null && bsid != null
else -> mcc != null && mnc != null && cid != null && (lac != null || tac != null)
}

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network
import android.location.Location
import android.os.Bundle
import androidx.core.location.LocationCompat
import androidx.core.os.bundleOf
import java.security.MessageDigest
const val TAG = "NetworkLocation"
const val LOCATION_EXTRA_PRECISION = "precision"
const val PROVIDER_CACHE = "cache"
const val PROVIDER_CACHE_NEGATIVE = "cache-"
val NEGATIVE_CACHE_ENTRY = Location(PROVIDER_CACHE_NEGATIVE)
internal operator fun <T> Bundle?.plus(pair: Pair<String, T>): Bundle = this + bundleOf(pair)
internal operator fun Bundle?.plus(other: Bundle): Bundle = when {
this == null -> other
else -> Bundle(this).apply { putAll(other) }
}
internal var Location.precision: Double
get() = extras?.getDouble(LOCATION_EXTRA_PRECISION, 1.0) ?: 1.0
set(value) {
extras += LOCATION_EXTRA_PRECISION to value
}
internal var Location.verticalAccuracy: Float?
get() = if (LocationCompat.hasVerticalAccuracy(this)) LocationCompat.getVerticalAccuracyMeters(this) else null
set(value) = if (value == null) LocationCompat.removeVerticalAccuracy(this) else LocationCompat.setVerticalAccuracyMeters(this, value)
fun ByteArray.toHexString(separator: String = "") : String = joinToString(separator) { "%02x".format(it) }
fun ByteArray.digest(md: String): ByteArray = MessageDigest.getInstance(md).digest(this)

View file

@ -0,0 +1,25 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class BluetoothBeacon(
/**
* The address of the Bluetooth Low Energy (BLE) beacon.
*/
val macAddress: String? = null,
/**
* The name of the BLE beacon.
*/
val name: String? = null,
/**
* The number of milliseconds since this BLE beacon was last seen.
*/
val age: Long? = null,
/**
* The measured signal strength of the BLE beacon in dBm.
*/
val signalStrength: Int? = null,
)

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class CellTower(
/**
* The type of radio network.
*/
val radioType: RadioType? = null,
/**
* The mobile country code.
*/
val mobileCountryCode: Int? = null,
/**
* The mobile network code.
*/
val mobileNetworkCode: Int? = null,
/**
* The location area code for GSM and WCDMA networks. The tracking area code for LTE networks.
*/
val locationAreaCode: Int? = null,
/**
* The cell id or cell identity.
*/
val cellId: Int? = null,
/**
* The number of milliseconds since this networks was last detected.
*/
val age: Long? = null,
/**
* The primary scrambling code for WCDMA and physical cell id for LTE.
*/
val psc: Int? = null,
/**
* The signal strength for this cell network, either the RSSI or RSCP.
*/
val signalStrength: Int? = null,
/**
* The timing advance value for this cell network.
*/
val timingAdvance: Int? = null,
)

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
/**
* By default, both a GeoIP based position fallback and a fallback based on cell location areas (lacs) are enabled. Omit the fallbacks section if you want to use the defaults. Change the values to false if you want to disable either of the fallbacks.
*/
data class Fallback(
/**
* If no exact cell match can be found, fall back from exact cell position estimates to more coarse grained cell location area estimates rather than going directly to an even worse GeoIP based estimate.
*/
val lacf: Boolean? = null,
/**
* If no position can be estimated based on any of the provided data points, fall back to an estimate based on a GeoIP database based on the senders IP address at the time of the query.
*/
val ipf: Boolean? = null
)

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class GeolocateRequest(
/**
* The clear text name of the cell carrier / operator.
*/
val carrier: String? = null,
/**
* Should the clients IP address be used to locate it; defaults to true.
*/
val considerIp: Boolean? = null,
/**
* The mobile country code stored on the SIM card.
*/
val homeMobileCountryCode: Int? = null,
/**
* The mobile network code stored on the SIM card.
*/
val homeMobileNetworkCode: Int? = null,
/**
* Same as the `radioType` entry in each cell record. If all the cell entries have the same `radioType`, it can be provided at the top level instead.
*/
val radioType: RadioType? = null,
val bluetoothBeacons: List<BluetoothBeacon>? = null,
val cellTowers: List<CellTower>? = null,
val wifiAccessPoints: List<WifiAccessPoint>? = null,
/**
* By default, both a GeoIP based position fallback and a fallback based on cell location areas (lacs) are enabled. Omit the fallbacks section if you want to use the defaults. Change the values to false if you want to disable either of the fallbacks.
*/
val fallbacks: Fallback? = null,
)

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class GeolocateResponse(
val location: ResponseLocation? = null,
val horizontalAccuracy: Double? = null,
val fallback: String? = null,
val error: ResponseError? = null,
// Custom
val verticalAccuracy: Double? = null,
val raw: List<RawGeolocateEntry> = emptyList(),
)

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class GeosubmitItem(
/**
* The time of observation of the data, measured in milliseconds since the UNIX epoch. Can be omitted if the observation time is very recent. The age values in each section are relative to this timestamp.
*/
val timestamp: Long? = null,
/**
* The position block contains information about where and when the data was observed.
*/
val position: GeosubmitPosition? = null,
val bluetoothBeacons: List<BluetoothBeacon>? = null,
val cellTowers: List<CellTower>? = null,
val wifiAccessPoints: List<WifiAccessPoint>? = null,
)

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class GeosubmitPosition(
/**
* The latitude of the observation (WSG 84).
*/
val latitude: Double? = null,
/**
* The longitude of the observation (WSG 84).
*/
val longitude: Double? = null,
/**
* The accuracy of the observed position in meters.
*/
val accuracy: Double? = null,
/**
* The altitude at which the data was observed in meters above sea-level.
*/
val altitude: Double? = null,
/**
* The accuracy of the altitude estimate in meters.
*/
val altitudeAccuracy: Double? = null,
/**
* The age of the position data (in milliseconds).
*/
val age: Long? = null,
/**
* The heading field denotes the direction of travel of the device and is specified in degrees, where 0° heading < 360°, counting clockwise relative to the true north.
*/
val heading: Double? = null,
/**
* The air pressure in hPa (millibar).
*/
val pressure: Double? = null,
/**
* The speed field denotes the magnitude of the horizontal component of the devices current velocity and is specified in meters per second.
*/
val speed: Double? = null,
/**
* The source of the position information. If the field is omitted, gps is assumed. The term gps is used to cover all types of satellite based positioning systems including Galileo and Glonass. Other possible values are manual for a position entered manually into the system and fused for a position obtained from a combination of other sensors or outside service queries.
*/
val source: GeosubmitSource? = null,
)

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class GeosubmitRequest(
val items: List<GeosubmitItem>? = null
)

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
enum class GeosubmitSource {
GPS, MANUAL, FUSED;
override fun toString(): String {
return super.toString().lowercase()
}
}

View file

@ -0,0 +1,241 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
import android.content.Context
import android.location.Location
import android.net.Uri
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import androidx.collection.LruCache
import com.android.volley.VolleyError
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import org.json.JSONObject
import org.microg.gms.location.LocationSettings
import org.microg.gms.location.formatRealtime
import org.microg.gms.location.network.*
import org.microg.gms.location.network.cell.CellDetails
import org.microg.gms.location.network.precision
import org.microg.gms.location.network.verticalAccuracy
import org.microg.gms.location.network.wifi.*
import org.microg.gms.location.provider.BuildConfig
import org.microg.gms.utils.singleInstanceOf
import java.io.PrintWriter
import java.nio.ByteBuffer
import java.util.LinkedList
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import kotlin.math.min
class IchnaeaServiceClient(private val context: Context) {
private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
private val settings = LocationSettings(context)
private val omitables = LinkedList<String>()
private val cache = LruCache<String, Location>(REQUEST_CACHE_SIZE)
private val start = SystemClock.elapsedRealtime()
private fun GeolocateRequest.hash(): ByteArray? {
if (cellTowers.isNullOrEmpty() && (wifiAccessPoints?.size ?: 0) < 3 || bluetoothBeacons?.isNotEmpty() == true) return null
val minAge = min(
cellTowers?.takeIf { it.isNotEmpty() }?.minOf { it.age?.takeIf { it > 0L } ?: 0L } ?: Long.MAX_VALUE,
wifiAccessPoints?.takeIf { it.isNotEmpty() }?.minOf { it.age?.takeIf { it > 0L } ?: 0L } ?: Long.MAX_VALUE
)
val buffer = ByteBuffer.allocate(8 + (cellTowers?.size ?: 0) * 23 + (wifiAccessPoints?.size ?: 0) * 8)
buffer.putInt(cellTowers?.size?: 0)
buffer.putInt(wifiAccessPoints?.size?: 0)
for (cell in cellTowers.orEmpty()) {
buffer.put(cell.radioType?.ordinal?.toByte() ?: -1)
buffer.putInt(cell.mobileCountryCode ?: -1)
buffer.putInt(cell.mobileNetworkCode ?: -1)
buffer.putInt(cell.locationAreaCode ?: -1)
buffer.putInt(cell.cellId ?: -1)
buffer.putInt(cell.psc ?: -1)
buffer.put(((cell.age?.let { it - minAge }?: 0L) / (60 * 1000)).toByte())
buffer.put(((cell.signalStrength ?: 0) / 20).toByte())
}
for (wifi in wifiAccessPoints.orEmpty()) {
buffer.put(wifi.macBytes)
buffer.put(((wifi.age?.let { it - minAge }?: 0L) / (60 * 1000)).toByte())
buffer.put(((wifi.signalStrength ?: 0) / 20).toByte())
}
return buffer.array().digest("SHA-256")
}
fun isRequestable(wifi: WifiDetails): Boolean {
return wifi.isRequestable && !omitables.contains(wifi.macClean)
}
suspend fun retrieveMultiWifiLocation(wifis: List<WifiDetails>, rawHandler: ((WifiDetails, Location) -> Unit)? = null): Location? = geoLocate(
GeolocateRequest(
considerIp = false,
wifiAccessPoints = wifis.filter { isRequestable(it) }.map(WifiDetails::toWifiAccessPoint),
fallbacks = Fallback(lacf = false, ipf = false)
),
rawWifiHandler = rawHandler
)?.apply {
precision = wifis.size.toDouble() / WIFI_BASE_PRECISION_COUNT
}
suspend fun retrieveSingleCellLocation(cell: CellDetails, rawHandler: ((CellDetails, Location) -> Unit)? = null): Location? = geoLocate(
GeolocateRequest(
considerIp = false,
radioType = cell.toCellTower().radioType,
homeMobileCountryCode = cell.toCellTower().mobileCountryCode,
homeMobileNetworkCode = cell.toCellTower().mobileNetworkCode,
cellTowers = listOf(cell.toCellTower()),
fallbacks = Fallback(
lacf = true,
ipf = false
)
),
rawCellHandler = rawHandler
)?.apply {
precision = if (extras?.getString(LOCATION_EXTRA_FALLBACK) != null) CELL_FALLBACK_PRECISION else CELL_DEFAULT_PRECISION
}
private suspend fun geoLocate(
request: GeolocateRequest,
rawWifiHandler: ((WifiDetails, Location) -> Unit)? = null,
rawCellHandler: ((CellDetails, Location) -> Unit)? = null
): Location? {
val requestHash = request.hash()
if (requestHash != null) {
val locationFromCache = cache[requestHash.toHexString()]
if (locationFromCache == NEGATIVE_CACHE_ENTRY) return null
if (locationFromCache != null) return Location(locationFromCache)
}
val response = rawGeoLocate(request)
Log.d(TAG, "$request -> $response")
for (entry in response.raw) {
if (entry.omit && entry.wifiAccessPoint?.macAddress != null) {
omitables.offer(entry.wifiAccessPoint.macAddress.lowercase().replace(":", ""))
if (omitables.size > OMITABLES_LIMIT) omitables.remove()
runCatching { rawWifiHandler?.invoke(entry.wifiAccessPoint.toWifiDetails(), NEGATIVE_CACHE_ENTRY) }
}
if (entry.omit && entry.cellTower?.radioType != null) {
runCatching { rawCellHandler?.invoke(entry.cellTower.toCellDetails(), NEGATIVE_CACHE_ENTRY) }
}
if (!entry.omit && entry.wifiAccessPoint?.macAddress != null && entry.location != null) {
val location = buildLocation(entry.location, entry.horizontalAccuracy, entry.verticalAccuracy).apply {
precision = 1.0
}
runCatching { rawWifiHandler?.invoke(entry.wifiAccessPoint.toWifiDetails(), location) }
}
if (!entry.omit && entry.cellTower?.radioType != null && entry.location != null) {
val location = buildLocation(entry.location, entry.horizontalAccuracy, entry.verticalAccuracy).apply {
precision = 1.0
}
runCatching { rawCellHandler?.invoke(entry.cellTower.toCellDetails(), location) }
}
}
val location = if (response.location != null) {
buildLocation(response.location, response.horizontalAccuracy, response.verticalAccuracy).apply {
if (response.fallback != null) extras = (extras ?: Bundle()).apply { putString(LOCATION_EXTRA_FALLBACK, response.fallback) }
}
} else if (response.error != null && response.error.code == 404) {
NEGATIVE_CACHE_ENTRY
} else if (response.error != null) {
throw ServiceException(response.error)
} else {
throw RuntimeException("Invalid response JSON")
}
if (requestHash != null) {
cache[requestHash.toHexString()] = if (location == NEGATIVE_CACHE_ENTRY) NEGATIVE_CACHE_ENTRY else Location(location)
}
if (location == NEGATIVE_CACHE_ENTRY) return null
return location
}
private fun buildLocation(location: ResponseLocation, defaultHorizontalAccuracy: Double? = null, defaultVerticalAccuracy: Double? = null): Location {
return Location(PROVIDER).apply {
latitude = location.latitude
longitude = location.longitude
if (location.altitude != null) altitude = location.altitude
if (defaultHorizontalAccuracy != null && defaultHorizontalAccuracy > 0.0) accuracy = defaultHorizontalAccuracy.toFloat()
if (hasAltitude() && defaultVerticalAccuracy != null && defaultVerticalAccuracy > 0.0) verticalAccuracy = defaultVerticalAccuracy.toFloat()
if (location.horizontalAccuracy != null && location.horizontalAccuracy > 0.0) accuracy = location.horizontalAccuracy.toFloat()
if (hasAltitude() && location.verticalAccuracy != null && location.verticalAccuracy > 0.0) verticalAccuracy = location.verticalAccuracy.toFloat()
time = System.currentTimeMillis()
}
}
private fun getRequestHeaders(): Map<String, String> = buildMap {
set("User-Agent", "${BuildConfig.ICHNAEA_USER_AGENT} (Linux; Android ${android.os.Build.VERSION.RELEASE}; ${context.packageName})")
if (settings.ichnaeaContribute) {
set("X-Ichnaea-Contribute-Opt-In", "1")
}
}
private fun continueError(continuation: Continuation<GeolocateResponse>, error: VolleyError) {
try {
val response = JSONObject(error.networkResponse.data.decodeToString()).toGeolocateResponse()
if (response.error != null) {
continuation.resume(response)
return
} else if (response.location?.latitude != null){
Log.w(TAG, "Received location in response with error code")
} else {
Log.w(TAG, "Received valid json without error in response with error code")
}
} catch (_: Exception) {
}
if (error.networkResponse != null) {
continuation.resume(GeolocateResponse(error = ResponseError(error.networkResponse.statusCode, error.message)))
return
}
continuation.resumeWithException(error)
}
private suspend fun rawGeoLocate(request: GeolocateRequest): GeolocateResponse = suspendCoroutine { continuation ->
val url = Uri.parse(settings.effectiveEndpoint).buildUpon().appendPath("v1").appendPath("geolocate").build().toString()
queue.add(object : JsonObjectRequest(Method.POST, url, request.toJson(), {
try {
continuation.resume(it.toGeolocateResponse())
} catch (e: Exception) {
continuation.resumeWithException(e)
}
}, {
continueError(continuation, it)
}) {
override fun getHeaders(): Map<String, String> = getRequestHeaders()
})
}
private suspend fun rawGeoSubmit(request: GeosubmitRequest): Unit = suspendCoroutine { continuation ->
val url = Uri.parse(settings.effectiveEndpoint).buildUpon().appendPath("v2").appendPath("geosubmit").build().toString()
queue.add(object : JsonObjectRequest(Method.POST, url, request.toJson(), {
continuation.resume(Unit)
}, {
continuation.resumeWithException(it)
}) {
override fun getHeaders(): Map<String, String> = getRequestHeaders()
})
}
fun dump(writer: PrintWriter) {
writer.println("Ichnaea start=${start.formatRealtime()} omitables=${omitables.size}")
writer.println("Ichnaea request cache size=${cache.size()} hits=${cache.hitCount()} miss=${cache.missCount()} puts=${cache.putCount()} evicts=${cache.evictionCount()}")
}
private operator fun <K : Any, V : Any> LruCache<K, V>.set(key: K, value: V) {
put(key, value)
}
companion object {
private const val TAG = "IchnaeaLocation"
private const val PROVIDER = "ichnaea"
const val WIFI_BASE_PRECISION_COUNT = 4.0
const val CELL_DEFAULT_PRECISION = 1.0
const val CELL_FALLBACK_PRECISION = 0.5
private const val OMITABLES_LIMIT = 100
private const val REQUEST_CACHE_SIZE = 200
const val LOCATION_EXTRA_FALLBACK = "fallback"
}
}

View file

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
enum class RadioType {
GSM, WCDMA, LTE;
override fun toString(): String {
return super.toString().lowercase()
}
}

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class RawGeolocateEntry(
val timestamp: Long? = null,
val bluetoothBeacon: BluetoothBeacon? = null,
val cellTower: CellTower? = null,
val wifiAccessPoint: WifiAccessPoint? = null,
val location: ResponseLocation? = null,
val horizontalAccuracy: Double? = null,
val verticalAccuracy: Double? = null,
val omit: Boolean = false,
)

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class ResponseError(
val code: Int? = null,
val message: String? = null
)

View file

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class ResponseLocation(
val latitude: Double,
val longitude: Double,
// Custom
val horizontalAccuracy: Double? = null,
val altitude: Double? = null,
val verticalAccuracy: Double? = null
)

View file

@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
class ServiceException(val error: ResponseError) : Exception(error.message)

View file

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
data class WifiAccessPoint(
/**
* The BSSID of the WiFi network.
*/
val macAddress: String,
/**
* The number of milliseconds since this network was last detected.
*/
val age: Long? = null,
/**
* The WiFi channel for networks in the 2.4GHz range. This often ranges from 1 to 13.
*/
val channel: Int? = null,
/**
* The frequency in MHz of the channel over which the client is communicating with the access point.
*/
val frequency: Int? = null,
/**
* The received signal strength (RSSI) in dBm.
*/
val signalStrength: Int? = null,
/**
* The current signal to noise ratio measured in dB.
*/
val signalToNoiseRatio: Int? = null,
/**
* The SSID of the Wifi network.
*/
val ssid: String? = null
) {
init {
if (ssid != null && ssid.endsWith("_nomap")) throw IllegalArgumentException("Wifi networks with a SSID ending in _nomap must not be collected.")
}
}

View file

@ -0,0 +1,260 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.ichnaea
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import org.microg.gms.location.network.cell.CellDetails
import org.microg.gms.location.network.wifi.WifiDetails
private fun JSONObject.getDouble(vararg names: String): Double {
for (name in names) {
if (has(name)) return getDouble(name)
}
throw JSONException("Values are all null: ${names.joinToString(", ")}")
}
private fun JSONObject.getInt(vararg names: String): Int {
for (name in names) {
if (has(name)) return getInt(name)
}
throw JSONException("Values are all null: ${names.joinToString(", ")}")
}
private fun JSONObject.getString(vararg names: String): String {
for (name in names) {
if (has(name)) return getString(name)
}
throw JSONException("Values are all null: ${names.joinToString(", ")}")
}
private fun JSONObject.optDouble(vararg names: String): Double? {
for (name in names) {
if (has(name)) optDouble(name).takeIf { it.isFinite() }?.let { return it }
}
return null
}
private fun JSONObject.optInt(vararg names: String): Int? {
for (name in names) {
runCatching { if (has(name)) return getInt(name) }
}
return null
}
private fun JSONObject.optLong(vararg names: String): Long? {
for (name in names) {
runCatching { if (has(name)) return getLong(name) }
}
return null
}
private fun JSONObject.optString(vararg names: String): String? {
for (name in names) {
if (has(name)) return optString(name)
}
return null
}
internal fun CellDetails.toCellTower() = CellTower(
radioType = when (type) {
CellDetails.Companion.Type.GSM -> RadioType.GSM
CellDetails.Companion.Type.WCDMA -> RadioType.WCDMA
CellDetails.Companion.Type.LTE -> RadioType.LTE
else -> throw IllegalArgumentException("Unsupported radio type")
},
mobileCountryCode = mcc,
mobileNetworkCode = mnc,
locationAreaCode = lac ?: tac,
cellId = cid?.toInt(),
age = timestamp?.let { System.currentTimeMillis() - it },
psc = pscOrPci,
signalStrength = signalStrength
)
internal fun CellTower.toCellDetails() = CellDetails(
type = when(radioType) {
RadioType.GSM -> CellDetails.Companion.Type.GSM
RadioType.WCDMA -> CellDetails.Companion.Type.WCDMA
RadioType.LTE -> CellDetails.Companion.Type.LTE
else -> throw IllegalArgumentException("Unsupported radio type")
},
mcc = mobileNetworkCode,
mnc = mobileNetworkCode,
lac = locationAreaCode,
tac = locationAreaCode,
cid = cellId?.toLong(),
pscOrPci = psc,
signalStrength = signalStrength
)
internal fun WifiDetails.toWifiAccessPoint() = WifiAccessPoint(
macAddress = macAddress,
age = timestamp?.let { System.currentTimeMillis() - it },
channel = channel,
frequency = frequency,
signalStrength = signalStrength,
ssid = ssid
)
internal fun WifiAccessPoint.toWifiDetails() = WifiDetails(
macAddress = macAddress,
channel = channel,
frequency = frequency,
signalStrength = signalStrength,
ssid = ssid,
)
internal fun JSONObject.toGeolocateResponse() = GeolocateResponse(
location = optJSONObject("location")?.toResponseLocation(),
horizontalAccuracy = optDouble("accuracy", "acc", "horizontalAccuracy"),
verticalAccuracy = optDouble("altAccuracy", "altitudeAccuracy", "verticalAccuracy"),
fallback = optString("fallback").takeIf { it.isNotEmpty() },
raw = optJSONArray("raw")?.toRawGeolocateEntries().orEmpty(),
error = optJSONObject("error")?.toResponseError()
)
internal fun GeolocateRequest.toJson() = JSONObject().apply {
if (carrier != null) put("carrier", carrier)
if (considerIp != null) put("considerIp", considerIp)
if (homeMobileCountryCode != null) put("homeMobileCountryCode", homeMobileCountryCode)
if (homeMobileNetworkCode != null) put("homeMobileNetworkCode", homeMobileNetworkCode)
if (radioType != null) put("radioType", radioType.toString())
if (!bluetoothBeacons.isNullOrEmpty()) put("bluetoothBeacons", JSONArray(bluetoothBeacons.map(BluetoothBeacon::toJson)))
if (!cellTowers.isNullOrEmpty()) put("cellTowers", JSONArray(cellTowers.map(CellTower::toJson)))
if (!wifiAccessPoints.isNullOrEmpty()) put("wifiAccessPoints", JSONArray(wifiAccessPoints.map(WifiAccessPoint::toJson)))
if (fallbacks != null) put("fallbacks", fallbacks.toJson())
}
internal fun GeosubmitRequest.toJson() = JSONObject().apply {
if (items != null) put("items", JSONArray(items.map(GeosubmitItem::toJson)))
}
private fun GeosubmitItem.toJson() = JSONObject().apply {
if (timestamp != null) put("timestamp", timestamp)
if (position != null) put("position", position.toJson())
if (!bluetoothBeacons.isNullOrEmpty()) put("bluetoothBeacons", JSONArray(bluetoothBeacons.map(BluetoothBeacon::toJson)))
if (!cellTowers.isNullOrEmpty()) put("cellTowers", JSONArray(cellTowers.map(CellTower::toJson)))
if (!wifiAccessPoints.isNullOrEmpty()) put("wifiAccessPoints", JSONArray(wifiAccessPoints.map(WifiAccessPoint::toJson)))
}
private fun GeosubmitPosition.toJson() = JSONObject().apply {
if (latitude != null) put("latitude", latitude)
if (longitude != null) put("longitude", longitude)
if (accuracy != null) put("accuracy", accuracy)
if (altitude != null) put("altitude", altitude)
if (altitudeAccuracy != null) put("altitudeAccuracy", altitudeAccuracy)
if (age != null) put("age", age)
if (heading != null) put("heading", heading)
if (pressure != null) put("pressure", pressure)
if (speed != null) put("speed", speed)
if (source != null) put("source", source.toString())
}
private fun JSONObject.toResponseLocation() = ResponseLocation(
latitude = getDouble("lat", "latitude"),
longitude = getDouble("lng", "longitude"),
altitude = optDouble("alt", "altitude"),
horizontalAccuracy = optDouble("acc", "accuracy", "horizontalAccuracy"),
verticalAccuracy = optDouble("altAccuracy", "altitudeAccuracy", "verticalAccuracy"),
)
private fun JSONObject.toResponseError() = ResponseError(
code = optInt("code", "errorCode", "statusCode"),
message = optString("message", "msg", "statusText")
)
private fun JSONArray.toRawGeolocateEntries(): List<RawGeolocateEntry> =
(0 until length()).mapNotNull { optJSONObject(it)?.toRawGeolocateEntry() }
private fun JSONObject.toRawGeolocateEntry() = RawGeolocateEntry(
timestamp = optLong("time", "timestamp"),
bluetoothBeacon = optJSONObject("bluetoothBeacon")?.toBluetoothBeacon(),
cellTower = optJSONObject("cellTower")?.toCellTower(),
wifiAccessPoint = optJSONObject("wifiAccessPoint")?.toWifiAccessPoint(),
location = optJSONObject("location")?.toResponseLocation(),
horizontalAccuracy = optDouble("acc", "accuracy", "horizontalAccuracy"),
verticalAccuracy = optDouble("altAccuracy", "altitudeAccuracy", "verticalAccuracy"),
omit = optBoolean("omit")
)
private fun JSONObject.toBluetoothBeacon() = BluetoothBeacon(
macAddress = getString("macAddress", "mac", "address"),
name = optString("name"),
)
private fun JSONObject.toCellTower() = CellTower(
radioType = optString("radioType", "radio", "type")?.let { runCatching { RadioType.valueOf(it.uppercase()) }.getOrNull() },
mobileCountryCode = optInt("mobileCountryCode", "mcc"),
mobileNetworkCode = optInt("mobileNetworkCode", "mnc"),
locationAreaCode = optInt("locationAreaCode", "lac", "trackingAreaCode", "tac"),
cellId = optInt("cellId", "cellIdentity", "cid"),
psc = optInt("psc", "primaryScramblingCode", "physicalCellId", "pci"),
)
private fun JSONObject.toWifiAccessPoint() = WifiAccessPoint(
macAddress = getString("macAddress", "mac", "bssid", "address"),
channel = optInt("channel", "chan"),
frequency = optInt("frequency", "freq"),
ssid = optString("ssid"),
)
private fun BluetoothBeacon.toJson() = JSONObject().apply {
if (macAddress != null) put("macAddress", macAddress)
if (name != null) put("name", name)
if (age != null) put("age", age)
if (signalStrength != null) put("signalStrength", signalStrength)
}
private fun CellTower.toJson() = JSONObject().apply {
if (radioType != null) put("radioType", radioType.toString())
if (mobileCountryCode != null) put("mobileCountryCode", mobileCountryCode)
if (mobileNetworkCode != null) put("mobileNetworkCode", mobileNetworkCode)
if (locationAreaCode != null) put("locationAreaCode", locationAreaCode)
if (cellId != null) put("cellId", cellId)
if (age != null) put("age", age)
if (psc != null) put("psc", psc)
if (signalStrength != null) put("signalStrength", signalStrength)
if (timingAdvance != null) put("timingAdvance", timingAdvance)
}
private fun WifiAccessPoint.toJson() = JSONObject().apply {
put("macAddress", macAddress)
if (age != null) put("age", age)
if (channel != null) put("channel", channel)
if (frequency != null) put("frequency", frequency)
if (signalStrength != null) put("signalStrength", signalStrength)
if (signalToNoiseRatio != null) put("signalToNoiseRatio", signalToNoiseRatio)
if (ssid != null) put("ssid", ssid)
}
val WifiAccessPoint.macClean: String
get() = macAddress.lowercase().replace(":", "")
val WifiAccessPoint.macBytes: ByteArray
get() {
val mac = macClean
return byteArrayOf(
mac.substring(0, 2).toInt(16).toByte(),
mac.substring(2, 4).toInt(16).toByte(),
mac.substring(4, 6).toInt(16).toByte(),
mac.substring(6, 8).toInt(16).toByte(),
mac.substring(8, 10).toInt(16).toByte(),
mac.substring(10, 12).toInt(16).toByte()
)
}
val GeolocateRequest.isWifiOnly: Boolean
get() = bluetoothBeacons.isNullOrEmpty() && cellTowers.isNullOrEmpty() && wifiAccessPoints?.isNotEmpty() == true
val GeolocateRequest.isCellOnly: Boolean
get() = bluetoothBeacons.isNullOrEmpty() && wifiAccessPoints.isNullOrEmpty() && cellTowers?.isNotEmpty() == true
private fun Fallback.toJson() = JSONObject().apply {
if (lacf != null) put("lacf", lacf)
if (ipf != null) put("ipf", ipf)
}

View file

@ -0,0 +1,515 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.wifi
import android.content.Context
import android.location.Location
import android.net.ConnectivityManager
import android.net.ConnectivityManager.TYPE_WIFI
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import androidx.core.content.getSystemService
import androidx.core.location.LocationCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import org.microg.gms.location.network.TAG
import java.net.HttpURLConnection
import java.net.Proxy
import java.net.URL
import java.security.KeyStore
import java.security.cert.*
import java.text.SimpleDateFormat
import java.util.*
import javax.net.ssl.CertPathTrustManagerParameters
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
private val MOVING_WIFI_HOTSPOTS = setOf(
// Austria
"OEBB",
"Austrian FlyNet",
"svciob", // OEBB Service WIFI
// Belgium
"THALYSNET",
// Canada
"Air Canada",
"ACWiFi",
"ACWiFi.com",
// Czech Republic
"CDWiFi",
// France
"_SNCF_WIFI_INOUI",
"_SNCF_WIFI_INTERCITES",
"_WIFI_LYRIA",
"OUIFI",
"NormandieTrainConnecte",
// Germany
"WIFIonICE",
"WIFI@DB",
"WiFi@DB",
"RRX Hotspot",
"FlixBux",
"FlixBus Wi-Fi",
"FlixTrain Wi-Fi",
"FlyNet",
"Telekom_FlyNet",
"Vestische WLAN",
"agilis-Wifi",
"freeWIFIahead!",
// Greece
"AegeanWiFi",
// Hong Kong
"Cathay Pacific",
// Hungary
"MAVSTART-WIFI",
// Netherlands
"KEOLIS Nederland",
// New Zealand
"AirNZ_InflightWiFi",
"Bluebridge WiFi",
// Singapore
"KrisWorld",
// Sweden
"SJ",
"saswifi",
// Switzerland
"SBB-Free",
"SBB-FREE",
"SWISS Connect",
"Edelweiss Entertainment",
// United Kingdom
"Avanti_Free_WiFi",
"CrossCountryWiFi",
"GWR WiFi",
"LNR On Board Wi-Fi",
"LOOP on train WiFi",
"WMR On Board Wi-Fi",
"EurostarTrainsWiFi",
// United States
"Amtrak_WiFi",
)
private val PHONE_HOTSPOT_KEYWORDS = setOf(
"iPhone",
"Galaxy",
"AndroidAP"
)
/**
* A Wi-Fi hotspot that changes its location dynamically and thus is unsuitable for use with location services that assume stable locations.
*
* Some moving Wi-Fi hotspots allow to determine their location when connected or through a public network API.
*/
val WifiDetails.isMoving: Boolean
get() {
if (MOVING_WIFI_HOTSPOTS.contains(ssid)) {
return true
}
if (PHONE_HOTSPOT_KEYWORDS.any { ssid?.contains(it) == true }) {
return true
}
return false
}
const val FEET_TO_METERS = 0.3048
const val KNOTS_TO_METERS_PER_SECOND = 0.5144
const val MILES_PER_HOUR_TO_METERS_PER_SECOND = 0.447
class MovingWifiHelper(private val context: Context) {
suspend fun retrieveMovingLocation(current: WifiDetails): Location {
if (!isLocallyRetrievable(current)) throw IllegalArgumentException()
val connectivityManager = context.getSystemService<ConnectivityManager>() ?: throw IllegalStateException()
val sources = MOVING_WIFI_HOTSPOTS_LOCALLY_RETRIEVABLE[current.ssid]!!
val exceptions = mutableListOf<Exception>()
for (source in sources) {
try {
val url = URL(source.url)
return withContext(Dispatchers.IO) {
val network = if (isLocallyRetrievable(current) && SDK_INT >= 23) {
@Suppress("DEPRECATION")
(connectivityManager.allNetworks.singleOrNull {
val networkInfo = connectivityManager.getNetworkInfo(it)
networkInfo?.type == TYPE_WIFI && networkInfo.isConnected
})
} else {
null
}
val connection = (if (SDK_INT >= 23) {
network?.openConnection(url, Proxy.NO_PROXY)
} else {
null
} ?: url.openConnection()) as HttpURLConnection
try {
connection.doInput = true
if (connection is HttpsURLConnection && SDK_INT >= 24) {
try {
val ctx = SSLContext.getInstance("TLS")
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
fun wrap(originalTrustManager: TrustManager): TrustManager {
if (originalTrustManager is X509TrustManager) {
return object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
Log.d(TAG, "checkClientTrusted: $chain, $authType")
originalTrustManager.checkClientTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
Log.d(TAG, "checkServerTrusted: $chain, $authType")
originalTrustManager.checkServerTrusted(chain, authType)
}
override fun getAcceptedIssuers(): Array<X509Certificate> {
return originalTrustManager.acceptedIssuers
}
}
} else {
return originalTrustManager
}
}
val ks = KeyStore.getInstance("AndroidCAStore")
ks.load(null, null)
tmf.init(ks)
ctx.init(null, tmf.trustManagers.map(::wrap).toTypedArray(), null)
connection.sslSocketFactory = ctx.socketFactory
} catch (e: Exception) {
Log.w(TAG, "Failed to disable revocation", e)
}
}
if (connection.responseCode != 200) throw RuntimeException("Got error")
val location = Location(current.ssid ?: "wifi")
source.parse(location, connection.inputStream.readBytes())
} finally {
connection.inputStream.close()
connection.disconnect()
}
}
} catch (e: Exception) {
exceptions.add(e)
}
}
if (exceptions.size == 1) throw exceptions.single()
throw RuntimeException(exceptions.joinToString("\n"))
}
fun isLocallyRetrievable(wifi: WifiDetails): Boolean =
MOVING_WIFI_HOTSPOTS_LOCALLY_RETRIEVABLE.containsKey(wifi.ssid)
companion object {
abstract class MovingWifiLocationSource(val url: String) {
abstract fun parse(location: Location, data: ByteArray): Location
}
private val SOURCE_WIFI_ON_ICE = object : MovingWifiLocationSource("https://iceportal.de/api1/rs/status") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
if (json.getString("gpsStatus") != "VALID") throw RuntimeException("GPS not valid")
location.accuracy = 100f
location.time = json.getLong("serverTime") - 15000L
location.latitude = json.getDouble("latitude")
location.longitude = json.getDouble("longitude")
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = (it / 3.6).toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
return location
}
}
private val SOURCE_OEBB_1 = object : MovingWifiLocationSource("https://railnet.oebb.at/assets/modules/fis/combined.json") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString()).getJSONObject("latestStatus")
location.accuracy = 100f
runCatching { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).parse(json.getString("dateTime"))?.time }.getOrNull()?.let { location.time = it }
location.latitude = json.getJSONObject("gpsPosition").getDouble("latitude")
location.longitude = json.getJSONObject("gpsPosition").getDouble("longitude")
json.getJSONObject("gpsPosition").optDouble("orientation").takeIf { !it.isNaN() }?.let {
location.bearing = it.toFloat()
LocationCompat.setBearingAccuracyDegrees(location, 90f)
}
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = (it / 3.6).toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
return location
}
}
private val SOURCE_OEBB_2 = object : MovingWifiLocationSource("https://railnet.oebb.at/api/gps") {
override fun parse(location: Location, data: ByteArray): Location {
val root = JSONObject(data.decodeToString())
if (root.has("JSON")) {
val json = root.getJSONObject("JSON")
if (!json.isNull("error")) throw RuntimeException("Error: ${json.get("error")}");
location.accuracy = 100f
location.latitude = json.getDouble("lat")
location.longitude = json.getDouble("lon")
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = (it / 3.6).toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
} else if (root.optDouble("Latitude").let { !it.isNaN() && it.isFinite() && it > 0.1 }) {
location.accuracy = 100f
location.latitude = root.getDouble("Latitude")
location.longitude = root.getDouble("Longitude")
} else {
throw RuntimeException("Unsupported: $root")
}
return location
}
}
private val SOURCE_FLIXBUS = object : MovingWifiLocationSource("https://media.flixbus.com/services/pis/v1/position") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
location.accuracy = 100f
location.latitude = json.getDouble("latitude")
location.longitude = json.getDouble("longitude")
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = it.toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
return location
}
}
class PassengeraLocationSource(base: String) : MovingWifiLocationSource("$base/portal/api/vehicle/realtime") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
location.accuracy = 100f
location.latitude = json.getDouble("gpsLat")
location.longitude = json.getDouble("gpsLng")
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = (it / 3.6).toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
json.optDouble("altitude").takeIf { !it.isNaN() }?.let { location.altitude = it }
return location
}
}
private val SOURCE_PASSENGERA_MAV = PassengeraLocationSource("http://portal.mav.hu")
private val SOURCE_PASSENGERA_CD = PassengeraLocationSource("http://cdwifi.cz")
private val SOURCE_DISPLAY_UGO = object : MovingWifiLocationSource("https://api.ife.ugo.aero/navigation/positions") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONArray(data.decodeToString()).getJSONObject(0)
location.accuracy = 100f
location.latitude = json.getDouble("latitude")
location.longitude = json.getDouble("longitude")
runCatching { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).parse(json.getString("created_at"))?.time }.getOrNull()?.let { location.time = it }
json.optDouble("speed_kilometers_per_hour").takeIf { !it.isNaN() }?.let {
location.speed = (it / 3.6).toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
json.optDouble("altitude_meters").takeIf { !it.isNaN() }?.let { location.altitude = it }
json.optDouble("bearing_in_degree").takeIf { !it.isNaN() }?.let {
location.bearing = it.toFloat()
LocationCompat.setBearingAccuracyDegrees(location, 90f)
}
return location
}
}
private val SOURCE_INFLIGHT_PANASONIC = object : MovingWifiLocationSource("https://services.inflightpanasonic.aero/inflight/services/flightdata/v2/flightdata") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
location.accuracy = 100f
location.latitude = json.getJSONObject("current_coordinates").getDouble("latitude")
location.longitude = json.getJSONObject("current_coordinates").getDouble("longitude")
json.optDouble("ground_speed_knots").takeIf { !it.isNaN() }?.let {
location.speed = (it * KNOTS_TO_METERS_PER_SECOND).toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
json.optDouble("altitude_feet").takeIf { !it.isNaN() }?.let { location.altitude = it * FEET_TO_METERS }
json.optDouble("true_heading_degree").takeIf { !it.isNaN() }?.let {
location.bearing = it.toFloat()
LocationCompat.setBearingAccuracyDegrees(location, 90f)
}
return location
}
}
class BoardConnectLocationSource(base: String) : MovingWifiLocationSource("$base/map/api/flightData") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
location.accuracy = 100f
location.latitude = json.getDouble("lat")
location.longitude = json.getDouble("lon")
runCatching { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).parse(json.getString("utc"))?.time }.getOrNull()?.let { location.time = it }
json.optDouble("groundSpeed").takeIf { !it.isNaN() }?.let {
location.speed = (it * KNOTS_TO_METERS_PER_SECOND).toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
json.optDouble("altitude").takeIf { !it.isNaN() }?.let { location.altitude = it * FEET_TO_METERS }
json.optDouble("heading").takeIf { !it.isNaN() }?.let {
location.bearing = it.toFloat()
LocationCompat.setBearingAccuracyDegrees(location, 90f)
}
return location
}
}
private val SOURCE_LUFTHANSA_FLYNET_EUROPE = BoardConnectLocationSource("https://www.lufthansa-flynet.com")
private val SOURCE_LUFTHANSA_FLYNET_EUROPE_2 = BoardConnectLocationSource("https://ww2.lufthansa-flynet.com")
private val SOURCE_AUSTRIAN_FLYNET_EUROPE = BoardConnectLocationSource("https://www.austrian-flynet.com")
class SncfLocationSource(base: String) : MovingWifiLocationSource("$base/router/api/train/gps") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
if(json.has("fix") && json.getInt("fix") == -1) throw RuntimeException("GPS not valid")
location.accuracy = 100f
location.latitude = json.getDouble("latitude")
location.longitude = json.getDouble("longitude")
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = it.toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
location.time = json.getLong("timestamp")
json.optDouble("heading").takeIf { !it.isNaN() }?.let {
location.bearing = it.toFloat()
LocationCompat.setBearingAccuracyDegrees(location, 90f)
}
return location
}
}
private val SOURCE_SNCF = SncfLocationSource("https://wifi.sncf")
private val SOURCE_SNCF_INTERCITES = SncfLocationSource("https://wifi.intercites.sncf")
private val SOURCE_NORMANDIE = SncfLocationSource("https://wifi.normandie.fr")
private val SOURCE_LYRIA = object : MovingWifiLocationSource("https://wifi.tgv-lyria.com/api/train/gps/position/") {
/* If there is no location available (e.g. in a tunnel), the API
endpoint returns HTTP 500, though it may reuse a previous
location for a few seconds. The returned JSON has a
"satellites" integer field, but this always seems to be 0, even
when there is a valid location available.
*/
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
location.accuracy = 100f
location.latitude = json.getDouble("latitude")
location.longitude = json.getDouble("longitude")
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
// Speed is returned in m/s.
location.speed = it.toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
json.optDouble("altitude").takeIf { !it.isNaN() }?.let { location.altitude = it }
return location
}
}
private val SOURCE_OUIFI = object : MovingWifiLocationSource("https://ouifi.ouigo.com:8084/api/gps") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
if(json.has("fix") && json.getInt("fix") == -1) throw RuntimeException("GPS not valid")
location.accuracy = 100f
location.latitude = json.getDouble("latitude")
location.longitude = json.getDouble("longitude")
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = it.toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
location.time = json.getLong("timestamp")
json.optDouble("heading").takeIf { !it.isNaN() }?.let {
location.bearing = it.toFloat()
LocationCompat.setBearingAccuracyDegrees(location, 90f)
}
return location
}
}
private val SOURCE_OMBORD = object : MovingWifiLocationSource("https://www.ombord.info/api/jsonp/position/") {
override fun parse(location: Location, data: ByteArray): Location {
// The API endpoint returns a JSONP object (even when no ?callback= is supplied), so strip the surrounding function call.
val json = JSONObject(data.decodeToString().trim().trim('(', ')', ';'))
// TODO: what happens in the Channel Tunnel? Does "satellites" go to zero? Does "mode" change?
if (json.has("satellites") && json.getInt("satellites") < 1) throw RuntimeException("Ombord has no GPS fix")
location.accuracy = 100f
location.latitude = json.getDouble("latitude")
location.longitude = json.getDouble("longitude")
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = it.toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
location.time = json.getLong("time")
// "cmg" means "course made good", i.e. the compass heading of the track over the ground.
// Sometimes gets stuck for a few minutes, so use a generous accuracy value.
json.optDouble("cmg").takeIf { !it.isNaN() }?.let {
location.bearing = it.toFloat()
LocationCompat.setBearingAccuracyDegrees(location, 90f)
}
json.optDouble("altitude").takeIf { !it.isNaN() }?.let {
location.altitude = it
}
return location
}
}
private val SOURCE_AIR_CANADA = object : MovingWifiLocationSource("https://airbornemedia.inflightinternet.com/asp/api/flight/info") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString()).getJSONObject("gpsData")
location.accuracy = 100f
location.latitude = json.getDouble("latitude")
location.longitude = json.getDouble("longitude")
json.optLong("utcTime").takeIf { it != 0L }?.let { location.time = it }
json.optDouble("altitude").takeIf { !it.isNaN() }?.let { location.altitude = it * FEET_TO_METERS }
json.optDouble("horizontalVelocity").takeIf { !it.isNaN() }?.let {
location.speed = (it * MILES_PER_HOUR_TO_METERS_PER_SECOND).toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
return location
}
}
private val SOURCE_HOTSPLOTS = object : MovingWifiLocationSource("http://hsp.hotsplots.net/status.json") {
override fun parse(location: Location, data: ByteArray): Location {
val json = JSONObject(data.decodeToString())
location.accuracy = 100f
location.latitude = json.getDouble("lat")
location.longitude = json.getDouble("lng")
json.optLong("ts").takeIf { it != 0L }?.let { location.time = it * 1000 }
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
location.speed = it.toFloat()
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
}
return location
}
}
private val MOVING_WIFI_HOTSPOTS_LOCALLY_RETRIEVABLE: Map<String, List<MovingWifiLocationSource>> = mapOf(
"WIFIonICE" to listOf(SOURCE_WIFI_ON_ICE),
"OEBB" to listOf(SOURCE_OEBB_2, SOURCE_OEBB_1),
"FlixBus" to listOf(SOURCE_FLIXBUS),
"FlixBus Wi-Fi" to listOf(SOURCE_FLIXBUS),
"FlixTrain Wi-Fi" to listOf(SOURCE_FLIXBUS),
"MAVSTART-WIFI" to listOf(SOURCE_PASSENGERA_MAV),
"AegeanWiFi" to listOf(SOURCE_DISPLAY_UGO),
"Telekom_FlyNet" to listOf(SOURCE_INFLIGHT_PANASONIC),
"Cathay Pacific" to listOf(SOURCE_INFLIGHT_PANASONIC),
"KrisWorld" to listOf(SOURCE_INFLIGHT_PANASONIC),
"SWISS Connect" to listOf(SOURCE_INFLIGHT_PANASONIC),
"Edelweiss Entertainment" to listOf(SOURCE_INFLIGHT_PANASONIC),
"FlyNet" to listOf(SOURCE_LUFTHANSA_FLYNET_EUROPE, SOURCE_LUFTHANSA_FLYNET_EUROPE_2),
"CDWiFi" to listOf(SOURCE_PASSENGERA_CD),
"Air Canada" to listOf(SOURCE_AIR_CANADA),
"ACWiFi" to listOf(SOURCE_AIR_CANADA),
"ACWiFi.com" to listOf(SOURCE_AIR_CANADA),
"OUIFI" to listOf(SOURCE_OUIFI),
"_SNCF_WIFI_INOUI" to listOf(SOURCE_SNCF),
"_SNCF_WIFI_INTERCITES" to listOf(SOURCE_SNCF_INTERCITES),
"_WIFI_LYRIA" to listOf(SOURCE_LYRIA),
"NormandieTrainConnecte" to listOf(SOURCE_NORMANDIE),
"agilis-Wifi" to listOf(SOURCE_HOTSPLOTS),
"Austrian FlyNet" to listOf(SOURCE_AUSTRIAN_FLYNET_EUROPE),
"EurostarTrainsWiFi" to listOf(SOURCE_OMBORD),
)
}
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.wifi
import org.microg.gms.location.network.NetworkDetails
data class WifiDetails(
val macAddress: String,
val ssid: String? = null,
val frequency: Int? = null,
val channel: Int? = null,
override val timestamp: Long? = null,
override val signalStrength: Int? = null,
val open: Boolean = false
): NetworkDetails

View file

@ -0,0 +1,11 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.wifi
interface WifiDetailsCallback {
fun onWifiDetailsAvailable(wifis: List<WifiDetails>)
fun onWifiSourceFailed()
}

View file

@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.wifi
import android.content.Context
import android.os.WorkSource
interface WifiDetailsSource {
fun enable() = Unit
fun disable() = Unit
fun startScan(workSource: WorkSource?) = Unit
companion object {
fun create(context: Context, callback: WifiDetailsCallback) = when {
WifiScannerSource.isSupported(context) -> WifiScannerSource(context, callback)
else -> WifiManagerSource(context, callback)
}
}
}

View file

@ -0,0 +1,41 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.wifi
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.wifi.ScanResult
import android.net.wifi.WifiManager
import android.os.WorkSource
import android.util.Log
import androidx.core.content.getSystemService
class WifiManagerSource(private val context: Context, private val callback: WifiDetailsCallback) : BroadcastReceiver(), WifiDetailsSource {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context, intent: Intent) {
try {
callback.onWifiDetailsAvailable(this.context.getSystemService<WifiManager>()?.scanResults.orEmpty().map(ScanResult::toWifiDetails))
} catch (e: Exception) {
Log.w(org.microg.gms.location.network.TAG, e)
}
}
override fun enable() {
context.registerReceiver(this, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION))
}
override fun disable() {
context.unregisterReceiver(this)
}
override fun startScan(workSource: WorkSource?) {
context.getSystemService<WifiManager>()?.startScan()
}
}

View file

@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.wifi
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.wifi.ScanResult
import android.net.wifi.WifiScanner
import android.os.Build.VERSION.SDK_INT
import android.os.WorkSource
import android.util.Log
import androidx.core.content.ContextCompat
import org.microg.gms.location.network.TAG
@SuppressLint("WrongConstant")
class WifiScannerSource(private val context: Context, private val callback: WifiDetailsCallback) : WifiDetailsSource {
override fun startScan(workSource: WorkSource?) {
val scanner = context.getSystemService("wifiscanner") as WifiScanner
scanner.startScan(WifiScanner.ScanSettings().apply {
band = WifiScanner.WIFI_BAND_BOTH
}, object : WifiScanner.ScanListener {
override fun onSuccess() {
Log.d(TAG, "Not yet implemented: onSuccess")
failed = false
}
override fun onFailure(reason: Int, description: String?) {
Log.d(TAG, "Not yet implemented: onFailure $reason $description")
failed = true
callback.onWifiSourceFailed()
}
@Deprecated("Not supported on all devices")
override fun onPeriodChanged(periodInMs: Int) {
Log.d(TAG, "Not yet implemented: onPeriodChanged $periodInMs")
}
override fun onResults(results: Array<out WifiScanner.ScanData>) {
callback.onWifiDetailsAvailable(results.flatMap { it.results.toList() }.map(ScanResult::toWifiDetails))
}
override fun onFullResult(fullScanResult: ScanResult) {
Log.d(TAG, "Not yet implemented: onFullResult $fullScanResult")
}
}, workSource)
}
companion object {
private var failed = false
fun isSupported(context: Context): Boolean {
return SDK_INT >= 26 && !failed && (context.getSystemService("wifiscanner") as? WifiScanner) != null && ContextCompat.checkSelfPermission(context, Manifest.permission.LOCATION_HARDWARE) == PERMISSION_GRANTED
}
}
}

View file

@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.network.wifi
import android.net.wifi.ScanResult
import android.net.wifi.WifiInfo
import android.os.Build.VERSION.SDK_INT
import android.os.SystemClock
import androidx.annotation.RequiresApi
internal fun ScanResult.toWifiDetails(): WifiDetails = WifiDetails(
macAddress = BSSID,
ssid = SSID,
timestamp = if (SDK_INT >= 19) System.currentTimeMillis() - (SystemClock.elapsedRealtime() - (timestamp / 1000)) else null,
frequency = frequency,
channel = frequencyToChannel(frequency),
signalStrength = level,
open = setOf("WEP", "WPA", "PSK", "EAP", "IEEE8021X", "PEAP", "TLS", "TTLS").none { capabilities.contains(it) }
)
@RequiresApi(31)
internal fun WifiInfo.toWifiDetails(): WifiDetails = WifiDetails(
macAddress = bssid,
ssid = ssid,
timestamp = System.currentTimeMillis(),
frequency = frequency,
signalStrength = rssi,
open = currentSecurityType == WifiInfo.SECURITY_TYPE_OPEN
)
private const val BAND_24_GHZ_FIRST_CH_NUM = 1
private const val BAND_24_GHZ_LAST_CH_NUM = 14
private const val BAND_5_GHZ_FIRST_CH_NUM = 32
private const val BAND_5_GHZ_LAST_CH_NUM = 177
private const val BAND_6_GHZ_FIRST_CH_NUM = 1
private const val BAND_6_GHZ_LAST_CH_NUM = 233
private const val BAND_60_GHZ_FIRST_CH_NUM = 1
private const val BAND_60_GHZ_LAST_CH_NUM = 6
private const val BAND_24_GHZ_START_FREQ_MHZ = 2412
private const val BAND_24_GHZ_END_FREQ_MHZ = 2484
private const val BAND_5_GHZ_START_FREQ_MHZ = 5160
private const val BAND_5_GHZ_END_FREQ_MHZ = 5885
private const val BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ = 5935
private const val BAND_6_GHZ_START_FREQ_MHZ = 5955
private const val BAND_6_GHZ_END_FREQ_MHZ = 7115
private const val BAND_60_GHZ_START_FREQ_MHZ = 58320
private const val BAND_60_GHZ_END_FREQ_MHZ = 70200
internal fun frequencyToChannel(freq: Int): Int? {
return when (freq) {
// Special cases
BAND_24_GHZ_END_FREQ_MHZ -> BAND_24_GHZ_LAST_CH_NUM
BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ -> 2
in BAND_24_GHZ_START_FREQ_MHZ..BAND_24_GHZ_END_FREQ_MHZ ->
(freq - BAND_24_GHZ_START_FREQ_MHZ) / 5 + BAND_24_GHZ_FIRST_CH_NUM
in BAND_5_GHZ_START_FREQ_MHZ..BAND_5_GHZ_END_FREQ_MHZ ->
(freq - BAND_5_GHZ_START_FREQ_MHZ) / 5 + BAND_5_GHZ_FIRST_CH_NUM
in BAND_6_GHZ_START_FREQ_MHZ..BAND_6_GHZ_END_FREQ_MHZ ->
(freq - BAND_6_GHZ_START_FREQ_MHZ) / 5 + BAND_6_GHZ_FIRST_CH_NUM
in BAND_60_GHZ_START_FREQ_MHZ..BAND_60_GHZ_END_FREQ_MHZ ->
(freq - BAND_60_GHZ_START_FREQ_MHZ) / 2160 + BAND_60_GHZ_FIRST_CH_NUM
else -> null
}
}
val WifiDetails.isNomap: Boolean
get() = ssid?.endsWith("_nomap") == true
val WifiDetails.isHidden: Boolean
get() = ssid == ""
val WifiDetails.isRequestable: Boolean
get() = !isNomap && !isHidden && !isMoving
val WifiDetails.macBytes: ByteArray
get() {
val mac = macClean
return byteArrayOf(
mac.substring(0, 2).toInt(16).toByte(),
mac.substring(2, 4).toInt(16).toByte(),
mac.substring(4, 6).toInt(16).toByte(),
mac.substring(6, 8).toInt(16).toByte(),
mac.substring(8, 10).toInt(16).toByte(),
mac.substring(10, 12).toInt(16).toByte()
)
}
val WifiDetails.macClean: String
get() = macAddress.lowercase().replace(":", "")

View file

@ -0,0 +1,70 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.content.Context
import android.location.LocationProvider
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import androidx.annotation.RequiresApi
import com.android.location.provider.LocationProviderBase
import com.android.location.provider.ProviderPropertiesUnbundled
import java.io.FileDescriptor
import java.io.PrintWriter
abstract class AbstractLocationProviderPreTiramisu : LocationProviderBase, GenericLocationProvider {
@Deprecated("Use only with SDK < 31")
constructor(properties: ProviderPropertiesUnbundled) : super(TAG, properties)
@RequiresApi(31)
constructor(context: Context, properties: ProviderPropertiesUnbundled) : super(context, TAG, properties)
private var statusUpdateTime = SystemClock.elapsedRealtime()
override fun onDump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) {
dump(pw)
}
override fun dump(writer: PrintWriter) {
// Nothing by default
}
override fun onFlush(callback: OnFlushCompleteCallback?) {
Log.d(TAG, "onFlush")
callback!!.onFlushComplete()
}
override fun onSendExtraCommand(command: String?, extras: Bundle?): Boolean {
Log.d(TAG, "onSendExtraCommand $command $extras")
return false
}
@Deprecated("Overriding this is required pre-Q, but not used since Q")
override fun onEnable() {
Log.d(TAG, "onEnable")
statusUpdateTime = SystemClock.elapsedRealtime()
}
@Deprecated("Overriding this is required pre-Q, but not used since Q")
override fun onDisable() {
Log.d(TAG, "onDisable")
statusUpdateTime = SystemClock.elapsedRealtime()
}
@Deprecated("Overriding this is required pre-Q, but not used since Q")
override fun onGetStatus(extras: Bundle?): Int {
Log.d(TAG, "onGetStatus $extras")
return LocationProvider.AVAILABLE
}
@Deprecated("Overriding this is required pre-Q, but not used since Q")
override fun onGetStatusUpdateTime(): Long {
Log.d(TAG, "onGetStatusUpdateTime")
return statusUpdateTime
}
}

View file

@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.location.Criteria
import android.location.Location
import android.os.Binder
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import androidx.core.location.LocationRequestCompat
import com.android.location.provider.ProviderPropertiesUnbundled
import com.android.location.provider.ProviderRequestUnbundled
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import kotlin.math.max
class FusedLocationProviderService : IntentLocationProviderService() {
override fun extractLocation(intent: Intent): Location? = LocationResult.extractResult(intent)?.lastLocation
@SuppressLint("MissingPermission")
override fun requestIntentUpdated(currentRequest: ProviderRequestUnbundled?, pendingIntent: PendingIntent) {
val intervalMillis = max(currentRequest?.interval ?: Long.MAX_VALUE, minIntervalMillis)
val request = LocationRequest.Builder(intervalMillis)
if (SDK_INT >= 31 && currentRequest != null) {
request.setPriority(when {
currentRequest.interval == LocationRequestCompat.PASSIVE_INTERVAL -> Priority.PRIORITY_PASSIVE
currentRequest.isLowPower -> Priority.PRIORITY_LOW_POWER
currentRequest.quality == LocationRequestCompat.QUALITY_LOW_POWER -> Priority.PRIORITY_LOW_POWER
currentRequest.quality == LocationRequestCompat.QUALITY_HIGH_ACCURACY -> Priority.PRIORITY_HIGH_ACCURACY
else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
})
request.setMaxUpdateDelayMillis(currentRequest.maxUpdateDelayMillis)
request.setWorkSource(currentRequest.workSource)
} else {
request.setPriority(when {
currentRequest?.interval == LocationRequestCompat.PASSIVE_INTERVAL -> Priority.PRIORITY_PASSIVE
else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
})
}
if (SDK_INT >= 29 && currentRequest != null) {
request.setBypass(currentRequest.isLocationSettingsIgnored)
}
val identity = Binder.clearCallingIdentity()
try {
LocationServices.getFusedLocationProviderClient(this).requestLocationUpdates(request.build(), pendingIntent)
} catch (e: SecurityException) {
Log.d(TAG, "Failed requesting location updated", e)
}
Binder.restoreCallingIdentity(identity)
}
override fun stopIntentUpdated(pendingIntent: PendingIntent) {
LocationServices.getFusedLocationProviderClient(this).removeLocationUpdates(pendingIntent)
}
override val minIntervalMillis: Long
get() = MIN_INTERVAL_MILLIS
override val minReportMillis: Long
get() = MIN_REPORT_MILLIS
override val properties: ProviderPropertiesUnbundled
get() = PROPERTIES
override val providerName: String
get() = "fused"
companion object {
private const val MIN_INTERVAL_MILLIS = 1000L
private const val MIN_REPORT_MILLIS = 1000L
private val PROPERTIES = ProviderPropertiesUnbundled.create(false, false, false, false, true, true, true, Criteria.POWER_LOW, Criteria.ACCURACY_COARSE)
}
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.location.Location
import android.os.IBinder
import java.io.PrintWriter
interface GenericLocationProvider {
fun getBinder(): IBinder
fun enable()
fun disable()
fun dump(writer: PrintWriter)
fun reportLocationToSystem(location: Location)
}

View file

@ -0,0 +1,29 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.app.Service
import android.content.Intent
import android.os.IBinder
import java.io.FileDescriptor
import java.io.PrintWriter
class GeocodeProviderService : Service() {
private var bound: Boolean = false
private var provider: OpenStreetMapNominatimGeocodeProvider? = null
override fun onBind(intent: Intent?): IBinder? {
if (provider == null) {
provider = OpenStreetMapNominatimGeocodeProvider(this)
}
bound = true
return provider?.binder
}
override fun dump(fd: FileDescriptor?, writer: PrintWriter?, args: Array<out String>?) {
provider?.dump(writer)
}
}

View file

@ -0,0 +1,124 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Intent
import android.location.Location
import android.location.LocationManager
import android.os.Build.VERSION.SDK_INT
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.os.WorkSource
import androidx.annotation.RequiresApi
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService
import com.android.location.provider.ProviderPropertiesUnbundled
import com.android.location.provider.ProviderRequestUnbundled
import org.microg.gms.location.elapsedMillis
import org.microg.gms.location.formatRealtime
import java.io.PrintWriter
import kotlin.math.max
class IntentLocationProviderPreTiramisu : AbstractLocationProviderPreTiramisu {
@Deprecated("Use only with SDK < 31")
constructor(service: IntentLocationProviderService, properties: ProviderPropertiesUnbundled, legacy: Unit) : super(properties) {
this.service = service
}
@RequiresApi(31)
constructor(service: IntentLocationProviderService, properties: ProviderPropertiesUnbundled) : super(service, properties) {
this.service = service
}
private val service: IntentLocationProviderService
private var enabled = false
private var currentRequest: ProviderRequestUnbundled? = null
private var pendingIntent: PendingIntent? = null
private var lastReportedLocation: Location? = null
private var lastReportTime: Long = 0
private val handler = Handler(Looper.getMainLooper())
private val reportAgainRunnable = Runnable { reportAgain() }
private fun updateRequest() {
if (enabled && pendingIntent != null) {
service.requestIntentUpdated(currentRequest, pendingIntent!!)
reportAgain()
}
}
override fun dump(writer: PrintWriter) {
writer.println("Enabled: $enabled")
writer.println("Current request: $currentRequest")
if (SDK_INT >= 31) writer.println("Current work source: ${currentRequest?.workSource}")
writer.println("Last reported: $lastReportedLocation")
writer.println("Last report time: ${lastReportTime.formatRealtime()}")
}
override fun onSetRequest(request: ProviderRequestUnbundled, source: WorkSource) {
synchronized(this) {
currentRequest = request
updateRequest()
}
}
override fun enable() {
synchronized(this) {
if (enabled) throw IllegalStateException()
val intent = Intent(service, service.javaClass)
intent.action = ACTION_REPORT_LOCATION
pendingIntent = PendingIntentCompat.getService(service, 0, intent, FLAG_UPDATE_CURRENT, true)
currentRequest = null
enabled = true
when {
SDK_INT >= 30 -> isAllowed = true
SDK_INT >= 29 -> isEnabled = true
}
try {
if (lastReportedLocation == null) {
lastReportedLocation = service.getSystemService<LocationManager>()?.getLastKnownLocation(service.providerName)
}
} catch (_: SecurityException) {
} catch (_: Exception) {
}
}
}
override fun disable() {
synchronized(this) {
if (!enabled || pendingIntent == null) throw IllegalStateException()
service.stopIntentUpdated(pendingIntent!!)
pendingIntent?.cancel()
pendingIntent = null
currentRequest = null
enabled = false
handler.removeCallbacks(reportAgainRunnable)
}
}
private fun reportAgain() {
// Report location again if it's recent enough
lastReportedLocation?.let {
if (it.elapsedMillis + max(currentRequest?.interval ?: 0, service.minIntervalMillis) > SystemClock.elapsedRealtime()) {
reportLocationToSystem(it)
}
}
}
override fun reportLocationToSystem(location: Location) {
handler.removeCallbacks(reportAgainRunnable)
location.provider = service.providerName
lastReportedLocation = location
lastReportTime = SystemClock.elapsedRealtime()
super.reportLocation(location)
val repeatInterval = max(service.minReportMillis, currentRequest?.interval ?: Long.MAX_VALUE)
if (repeatInterval < service.minIntervalMillis) {
handler.postDelayed(reportAgainRunnable, repeatInterval)
}
}
}

View file

@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.location.Location
import android.os.Binder
import android.os.Build.VERSION.SDK_INT
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.Process
import android.util.Log
import com.android.location.provider.ProviderPropertiesUnbundled
import com.android.location.provider.ProviderRequestUnbundled
import java.io.FileDescriptor
import java.io.PrintWriter
abstract class IntentLocationProviderService : Service() {
private lateinit var handlerThread: HandlerThread
private lateinit var handler: Handler
private var bound: Boolean = false
private var provider: GenericLocationProvider? = null
override fun onCreate() {
super.onCreate()
handlerThread = HandlerThread(this.javaClass.simpleName)
handlerThread.start()
handler = Handler(handlerThread.looper)
}
abstract fun requestIntentUpdated(currentRequest: ProviderRequestUnbundled?, pendingIntent: PendingIntent)
abstract fun stopIntentUpdated(pendingIntent: PendingIntent)
abstract fun extractLocation(intent: Intent): Location?
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (Binder.getCallingUid() == Process.myUid() && intent?.action == ACTION_REPORT_LOCATION) {
handler.post {
val location = extractLocation(intent)
if (location != null) {
provider?.reportLocationToSystem(location)
}
}
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? {
bound = true
if (provider == null) {
provider = when {
// TODO: Migrate to Tiramisu provider. Not yet required thanks to backwards compat
// SDK_INT >= 33 ->
SDK_INT >= 31 ->
IntentLocationProviderPreTiramisu(this, properties)
else ->
@Suppress("DEPRECATION")
(IntentLocationProviderPreTiramisu(this, properties, Unit))
}
provider?.enable()
}
return provider?.getBinder()
}
override fun dump(fd: FileDescriptor, writer: PrintWriter, args: Array<out String>) {
writer.println("Bound: $bound")
provider?.dump(writer)
}
override fun onDestroy() {
if (SDK_INT >= 18) handlerThread.looper.quitSafely()
else handlerThread.looper.quit()
provider?.disable()
provider = null
bound = false
super.onDestroy()
}
abstract val minIntervalMillis: Long
abstract val minReportMillis: Long
abstract val properties: ProviderPropertiesUnbundled
abstract val providerName: String
}

View file

@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.app.PendingIntent
import android.content.Intent
import android.location.Criteria
import android.location.Location
import android.location.LocationManager
import android.os.Build.VERSION.SDK_INT
import com.android.location.provider.ProviderPropertiesUnbundled
import com.android.location.provider.ProviderRequestUnbundled
import org.microg.gms.location.*
import org.microg.gms.location.network.LOCATION_EXTRA_PRECISION
import kotlin.math.max
class NetworkLocationProviderService : IntentLocationProviderService() {
override fun extractLocation(intent: Intent): Location? = intent.getParcelableExtra<Location?>(EXTRA_LOCATION)?.apply {
extras?.remove(LOCATION_EXTRA_PRECISION)
}
override fun requestIntentUpdated(currentRequest: ProviderRequestUnbundled?, pendingIntent: PendingIntent) {
val forceNow: Boolean
val intervalMillis: Long
if (currentRequest?.reportLocation == true) {
forceNow = true
intervalMillis = max(currentRequest.interval ?: Long.MAX_VALUE, minIntervalMillis)
} else {
forceNow = false
intervalMillis = Long.MAX_VALUE
}
val intent = Intent(ACTION_NETWORK_LOCATION_SERVICE)
intent.`package` = packageName
intent.putExtra(EXTRA_PENDING_INTENT, pendingIntent)
intent.putExtra(EXTRA_ENABLE, true)
intent.putExtra(EXTRA_INTERVAL_MILLIS, intervalMillis)
intent.putExtra(EXTRA_FORCE_NOW, forceNow)
if (SDK_INT >= 31) {
intent.putExtra(EXTRA_LOW_POWER, currentRequest?.isLowPower ?: false)
intent.putExtra(EXTRA_WORK_SOURCE, currentRequest?.workSource)
}
if (SDK_INT >= 29) {
intent.putExtra(EXTRA_BYPASS, currentRequest?.isLocationSettingsIgnored ?: false)
}
startService(intent)
}
override fun stopIntentUpdated(pendingIntent: PendingIntent) {
val intent = Intent(ACTION_NETWORK_LOCATION_SERVICE)
intent.`package` = packageName
intent.putExtra(EXTRA_PENDING_INTENT, pendingIntent)
intent.putExtra(EXTRA_ENABLE, false)
startService(intent)
}
override val minIntervalMillis: Long
get() = MIN_INTERVAL_MILLIS
override val minReportMillis: Long
get() = MIN_REPORT_MILLIS
override val properties: ProviderPropertiesUnbundled
get() = PROPERTIES
override val providerName: String
get() = LocationManager.NETWORK_PROVIDER
companion object {
private const val MIN_INTERVAL_MILLIS = 20000L
private const val MIN_REPORT_MILLIS = 1000L
private val PROPERTIES = ProviderPropertiesUnbundled.create(false, false, false, false, true, true, true, Criteria.POWER_LOW, Criteria.ACCURACY_COARSE)
}
}

View file

@ -0,0 +1,188 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.content.Context
import android.location.Address
import android.location.GeocoderParams
import android.net.Uri
import android.util.Log
import android.util.LruCache
import com.android.location.provider.GeocodeProvider
import com.android.volley.toolbox.JsonArrayRequest
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import com.google.android.gms.location.internal.ClientIdentity
import org.json.JSONObject
import org.microg.address.Formatter
import org.microg.gms.location.LocationSettings
import org.microg.gms.utils.singleInstanceOf
import java.io.PrintWriter
import java.util.*
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
class OpenStreetMapNominatimGeocodeProvider(private val context: Context) : GeocodeProvider() {
private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
private val formatter = runCatching { Formatter() }.getOrNull()
private val addressCache = LruCache<CacheKey, Address>(CACHE_SIZE)
private val settings by lazy { LocationSettings(context) }
override fun onGetFromLocation(latitude: Double, longitude: Double, maxResults: Int, params: GeocoderParams, addresses: MutableList<Address>): String? {
val clientIdentity = params.clientIdentity ?: return "null client package"
val locale = params.locale ?: return "null locale"
if (!settings.geocoderNominatim) return "disabled"
val cacheKey = CacheKey(clientIdentity, locale, latitude, longitude)
addressCache[cacheKey]?.let {address ->
addresses.add(address)
return null
}
val uri = Uri.Builder()
.scheme("https").authority(NOMINATIM_SERVER).path("/reverse")
.appendQueryParameter("format", "json")
.appendQueryParameter("accept-language", locale.language)
.appendQueryParameter("addressdetails", "1")
.appendQueryParameter("lat", latitude.toString())
.appendQueryParameter("lon", longitude.toString())
val result = AtomicReference<String?>("timeout reached")
val returnedAddress = AtomicReference<Address?>(null)
val latch = CountDownLatch(1)
queue.add(object : JsonObjectRequest(uri.build().toString(), {
parseResponse(locale, it)?.let(returnedAddress::set)
result.set(null)
latch.countDown()
}, {
result.set(it.message)
latch.countDown()
}) {
override fun getHeaders(): Map<String, String> = mapOf("User-Agent" to "microG/${context.versionName}")
})
latch.await(5, TimeUnit.SECONDS)
val address = returnedAddress.get()
if (address != null) {
Log.d(TAG, "Returned $address for $latitude,$longitude")
addresses.add(address)
addressCache.put(cacheKey, address)
}
return result.get()
}
override fun onGetFromLocationName(
locationName: String,
lowerLeftLatitude: Double,
lowerLeftLongitude: Double,
upperRightLatitude: Double,
upperRightLongitude: Double,
maxResults: Int,
params: GeocoderParams,
addresses: MutableList<Address>
): String? {
val clientIdentity = params.clientIdentity ?: return "null client package"
val locale = params.locale ?: return "null locale"
if (!settings.geocoderNominatim) return "disabled"
val uri = Uri.Builder()
.scheme("https").authority(NOMINATIM_SERVER).path("/search")
.appendQueryParameter("format", "json")
.appendQueryParameter("accept-language", locale.language)
.appendQueryParameter("addressdetails", "1")
.appendQueryParameter("bounded", "1")
.appendQueryParameter("q", locationName)
.appendQueryParameter("limit", maxResults.toString())
if (lowerLeftLatitude != upperRightLatitude && lowerLeftLongitude != upperRightLongitude) {
uri.appendQueryParameter("viewbox", "$lowerLeftLongitude,$upperRightLatitude,$upperRightLongitude,$lowerLeftLatitude")
}
val result = AtomicReference<String?>("timeout reached")
val latch = CountDownLatch(1)
queue.add(object : JsonArrayRequest(uri.build().toString(), {
for (i in 0 until it.length()) {
parseResponse(locale, it.getJSONObject(i))?.let(addresses::add)
}
result.set(null)
latch.countDown()
}, {
result.set(it.message)
latch.countDown()
}) {
override fun getHeaders(): Map<String, String> = mapOf("User-Agent" to "microG/${context.versionName}")
})
latch.await(5, TimeUnit.SECONDS)
return result.get()
}
private fun parseResponse(locale: Locale, result: JSONObject): Address? {
if (!result.has(WIRE_LATITUDE) || !result.has(WIRE_LONGITUDE) ||
!result.has(WIRE_ADDRESS)
) {
return null
}
Log.d(TAG, "Result: $result")
val address = Address(locale)
address.latitude = result.getDouble(WIRE_LATITUDE)
address.longitude = result.getDouble(WIRE_LONGITUDE)
val a = result.getJSONObject(WIRE_ADDRESS)
address.thoroughfare = a.optString(WIRE_THOROUGHFARE)
address.subLocality = a.optString(WIRE_SUBLOCALITY)
address.postalCode = a.optString(WIRE_POSTALCODE)
address.subAdminArea = a.optString(WIRE_SUBADMINAREA)
address.adminArea = a.optString(WIRE_ADMINAREA)
address.countryName = a.optString(WIRE_COUNTRYNAME)
address.countryCode = a.optString(WIRE_COUNTRYCODE)
if (a.has(WIRE_LOCALITY_CITY)) {
address.locality = a.getString(WIRE_LOCALITY_CITY)
} else if (a.has(WIRE_LOCALITY_TOWN)) {
address.locality = a.getString(WIRE_LOCALITY_TOWN)
} else if (a.has(WIRE_LOCALITY_VILLAGE)) {
address.locality = a.getString(WIRE_LOCALITY_VILLAGE)
}
if (formatter != null) {
val components = mutableMapOf<String, String>()
for (s in a.keys()) {
if (s !in WIRE_IGNORED) {
components[s] = a[s].toString()
}
}
val split = formatter.formatAddress(components).split("\n")
for (i in split.indices) {
address.setAddressLine(i, split[i])
}
address.featureName = formatter.guessName(components)
}
return address
}
fun dump(writer: PrintWriter?) {
writer?.println("Enabled: ${settings.geocoderNominatim}")
writer?.println("Address cache: size=${addressCache.size()} hits=${addressCache.hitCount()} miss=${addressCache.missCount()} puts=${addressCache.putCount()} evicts=${addressCache.evictionCount()}")
}
companion object {
private const val CACHE_SIZE = 200
private const val NOMINATIM_SERVER = "nominatim.openstreetmap.org"
private const val WIRE_LATITUDE = "lat"
private const val WIRE_LONGITUDE = "lon"
private const val WIRE_ADDRESS = "address"
private const val WIRE_THOROUGHFARE = "road"
private const val WIRE_SUBLOCALITY = "suburb"
private const val WIRE_POSTALCODE = "postcode"
private const val WIRE_LOCALITY_CITY = "city"
private const val WIRE_LOCALITY_TOWN = "town"
private const val WIRE_LOCALITY_VILLAGE = "village"
private const val WIRE_SUBADMINAREA = "county"
private const val WIRE_ADMINAREA = "state"
private const val WIRE_COUNTRYNAME = "country"
private const val WIRE_COUNTRYCODE = "country_code"
private val WIRE_IGNORED = setOf<String>("ISO3166-2-lvl4")
private data class CacheKey(val uid: Int, val packageName: String?, val locale: Locale, val latitude: Int, val longitude: Int) {
constructor(clientIdentity: ClientIdentity, locale: Locale, latitude: Double, longitude: Double) : this(clientIdentity.uid, clientIdentity.packageName.takeIf { clientIdentity.uid != 0 }, locale, (latitude * 100000.0).toInt(), (longitude * 100000.0).toInt())
}
}
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.provider
import android.content.Context
import android.location.GeocoderParams
import android.os.Build.VERSION.SDK_INT
import com.google.android.gms.location.internal.ClientIdentity
const val TAG = "LocationProvider"
const val ACTION_REPORT_LOCATION = "org.microg.gms.location.provider.ACTION_REPORT_LOCATION"
val GeocoderParams.clientIdentity: ClientIdentity?
get() = clientPackage?.let {
ClientIdentity(it).apply {
if (SDK_INT >= 33) {
uid = clientUid
attributionTag = clientAttributionTag
}
}
}
val Context.versionName: String?
get() = packageManager.getPackageInfo(packageName, 0).versionName

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2024 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<paths>
<cache-path name="location" path="location" />
</paths>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2020 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="org.microg.gms.location.manager.AskPermissionNotificationActivity"
android:excludeFromRecents="true"
android:process=":ui"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar"
android:exported="false" />
<service
android:name="org.microg.gms.location.manager.LocationManagerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.location.internal.GoogleLocationManagerService.START" />
</intent-filter>
</service>
<service
android:name="org.microg.gms.location.reporting.ReportingAndroidService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.location.reporting.service.START" />
<action android:name="com.google.android.gms.location.reporting.service.START" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -0,0 +1,270 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.Manifest.permission.*
import android.annotation.TargetApi
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.graphics.Typeface
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.provider.Settings
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.util.Log
import android.view.View
import android.widget.TextView
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService
import org.microg.gms.location.core.BuildConfig
import org.microg.gms.location.core.R
import org.microg.gms.utils.getApplicationLabel
private const val ACTION_ASK = "org.microg.gms.location.manager.ASK_PERMISSION"
private const val ACTION_CANCEL = "org.microg.gms.location.manager.ASK_PERMISSION_CANCEL"
@RequiresApi(23)
class AskPermissionNotificationActivity : AppCompatActivity() {
private val foregroundRequestCode = 5
private val backgroundRequestCode = 55
private val sharedPreferences by lazy {
getSharedPreferences(SHARED_PREFERENCE_NAME, MODE_PRIVATE)
}
private lateinit var hintView: View
private lateinit var rationaleTextView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent?.action == ACTION_CANCEL) {
hideLocationPermissionNotification(this)
finish()
return
}
setContentView(R.layout.extended_permission_request)
rationaleTextView = findViewById(R.id.rationale_textview)
if (checkAllPermissions()) {
hideLocationPermissionNotification(this)
finish()
return
} else if (isGranted(ACCESS_COARSE_LOCATION) && isGranted(ACCESS_FINE_LOCATION) && !isGranted(ACCESS_BACKGROUND_LOCATION) && SDK_INT >= 29) {
requestBackground()
} else {
requestForeground()
}
findViewById<View>(R.id.open_setting_tv).setOnClickListener {
val intent = Intent()
intent.action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
val uri = Uri.fromParts("package", packageName, null)
intent.data = uri
startActivityForResult(intent, 123)
}
findViewById<View>(R.id.decline_remind_tv).setOnClickListener {
val editor = sharedPreferences.edit()
editor.putBoolean(PERMISSION_REJECT_SHOW, true)
editor.apply()
finish()
}
hintView = findViewById(R.id.hint_sl)
val hintTitle = getString(R.string.permission_hint_title)
val builder = SpannableStringBuilder(hintTitle + getString(R.string.permission_hint))
val span = ForegroundColorSpan(Color.BLACK)
builder.setSpan(span, 0, hintTitle.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
builder.setSpan(StyleSpan(Typeface.BOLD), 0, hintTitle.length, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
val hintContentTv = findViewById<TextView>(R.id.hint_content_tv)
hintContentTv.text = builder
hintView.visibility = View.VISIBLE
}
private fun checkAllPermissions(): Boolean {
if (SDK_INT < 23) return true
return if (SDK_INT >= 29) {
isGranted(ACCESS_COARSE_LOCATION)
&& isGranted(ACCESS_FINE_LOCATION)
&& isGranted(ACCESS_BACKGROUND_LOCATION)
} else {
isGranted(ACCESS_COARSE_LOCATION)
&& isGranted(ACCESS_FINE_LOCATION)
}
}
private fun isGranted(permission: String): Boolean {
return checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}
private fun checkAndAddPermission(list: ArrayList<String>, permission: String) {
val result = checkSelfPermission(permission)
Log.i(TAG, "$permission: $result")
if (result != PackageManager.PERMISSION_GRANTED) {
list.add(permission)
}
}
private fun requestForeground() {
val appName = packageManager.getApplicationLabel(packageName)
rationaleTextView.text = getString(R.string.rationale_foreground_permission, appName)
val permissions = arrayListOf<String>()
if (BuildConfig.FORCE_SHOW_BACKGROUND_PERMISSION.isNotEmpty()) {
permissions.add(BuildConfig.FORCE_SHOW_BACKGROUND_PERMISSION)
}
checkAndAddPermission(permissions, ACCESS_COARSE_LOCATION)
checkAndAddPermission(permissions, ACCESS_FINE_LOCATION)
if (SDK_INT == 29) {
rationaleTextView.text = getString(R.string.rationale_permission, appName)
checkAndAddPermission(permissions, ACCESS_BACKGROUND_LOCATION)
}
requestPermissions(permissions, foregroundRequestCode)
}
private fun requestBackground() {
rationaleTextView.setText(R.string.rationale_background_permission)
val permissions = arrayListOf<String>()
if (BuildConfig.FORCE_SHOW_BACKGROUND_PERMISSION.isNotEmpty()) {
permissions.add(BuildConfig.FORCE_SHOW_BACKGROUND_PERMISSION)
}
if (SDK_INT >= 29) {
checkAndAddPermission(permissions, ACCESS_BACKGROUND_LOCATION)
}
requestPermissions(permissions, backgroundRequestCode)
}
private fun requestPermissions(permissions: ArrayList<String>, requestCode: Int) {
if (permissions.isNotEmpty()) {
Log.w(TAG, "Request permissions: $permissions")
ActivityCompat.requestPermissions(this, permissions.toTypedArray(), requestCode)
} else {
Log.i(TAG, "All permission granted")
setResult(RESULT_OK)
finish()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
Log.d(TAG, "onActivityResult: ")
checkPermissions()
}
private fun checkPermissions() {
val permissions = mutableListOf(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION)
if (SDK_INT >= 29) permissions.add(ACCESS_BACKGROUND_LOCATION)
if (permissions.all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }) {
Log.d(TAG, "location permission is all granted")
hideLocationPermissionNotification(this)
finish()
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
when (requestCode) {
foregroundRequestCode -> {
for (i in permissions.indices) {
val p = permissions[i]
val grant = grantResults[i]
val msg = if (grant == PackageManager.PERMISSION_GRANTED) "GRANTED" else "DENIED"
Log.w(TAG, "$p: $grant - $msg")
}
requestBackground()
}
backgroundRequestCode -> {
if (isGranted(ACCESS_BACKGROUND_LOCATION)) {
hideLocationPermissionNotification(this)
setResult(RESULT_OK)
finish()
} else {
reject()
}
}
else -> {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
reject()
}
}
}
private fun reject() {
hintView.visibility = View.VISIBLE
}
companion object {
private const val SHARED_PREFERENCE_NAME = "location_perm_notify"
const val PERMISSION_REJECT_SHOW = "permission_reject_show"
private const val NOTIFICATION_ID = 1026359765
private const val ASK_REQUEST_CODE = 1026359766
private const val CANCEL_REQUEST_CODE = 1026359767
@JvmStatic
fun showLocationPermissionNotification(context: Context) {
val appName = context.packageManager.getApplicationLabel(context.packageName).toString()
val title = context.getString(R.string.location_permission_notification_title, appName)
val backgroundPermissionOption =
if (SDK_INT >= 30) context.packageManager.backgroundPermissionOptionLabel else context.getString(R.string.location_permission_background_option_name)
val text = context.getString(R.string.location_permission_notification_content, backgroundPermissionOption, appName)
val notification = NotificationCompat.Builder(context, createNotificationChannel(context))
.setContentTitle(title).setContentText(text)
.setSmallIcon(R.drawable.ic_permission_notification)
.setContentIntent(PendingIntentCompat.getActivity(context, ASK_REQUEST_CODE, Intent(context, AskPermissionNotificationActivity::class.java).apply { action = ACTION_ASK }, 0, false))
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.setDeleteIntent(PendingIntentCompat.getActivity(context, CANCEL_REQUEST_CODE, Intent(context, AskPermissionNotificationActivity::class.java).apply { action = ACTION_CANCEL}, 0, false))
.build()
context.getSystemService<NotificationManager>()?.notify(NOTIFICATION_ID, notification)
}
@JvmStatic
fun hideLocationPermissionNotification(context: Context) {
context.getSystemService<NotificationManager>()?.cancel(NOTIFICATION_ID)
}
@TargetApi(26)
private fun createNotificationChannel(context: Context): String {
val channelId = "missing-location-permission"
if (SDK_INT >= 26) {
val channel = NotificationChannel(channelId, "Missing location permission", NotificationManager.IMPORTANCE_HIGH)
channel.setSound(null, null)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
channel.setShowBadge(true)
if (SDK_INT >= 29) {
channel.setAllowBubbles(false)
}
channel.vibrationPattern = longArrayOf(0)
context.getSystemService<NotificationManager>()?.createNotificationChannel(channel)
return channel.id
}
return channelId
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View file

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M1,21h22L12,2 1,21zM13,18h-2v-2h2v2zM13,14h-2v-4h2v4z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View file

@ -0,0 +1,113 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/rationale_textview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_margin="16dp"
android:textAppearance="@android:style/TextAppearance.Large"
tools:text="Need this permission for ..." />
<LinearLayout
android:id="@+id/hint_sl"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:orientation="vertical"
android:paddingStart="30dp"
android:paddingTop="10dp"
android:paddingEnd="30dp"
android:visibility="gone"
tools:visibility="visible">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="none">
<TextView
android:id="@+id/hint_content_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@android:color/darker_gray"
android:text="@string/permission_hint"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/permission_setting_hint_title"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold" />
<ImageView
android:layout_width="match_parent"
android:layout_height="217dp"
android:layout_marginTop="5dp"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
android:scaleType="fitCenter"
android:src="@drawable/permission_step_1" />
<ImageView
android:layout_width="25dp"
android:layout_height="33dp"
android:layout_gravity="center_horizontal"
android:src="@drawable/arrow" />
<ImageView
android:layout_width="match_parent"
android:layout_height="300dp"
android:layout_marginTop="5dp"
android:layout_marginStart="25dp"
android:layout_marginEnd="25dp"
android:scaleType="fitCenter"
android:src="@drawable/permission_step_2" />
</LinearLayout>
</ScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_marginBottom="12dp"
android:layout_height="wrap_content">
<Button
android:id="@+id/open_setting_tv"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:gravity="center"
android:text="@string/open_settings"
android:textSize="14sp" />
<Button
android:id="@+id/decline_remind_tv"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="16dp"
android:layout_weight="1"
android:gravity="center"
android:text="@string/dont_remind_again"
android:textSize="14sp"
android:layout_marginLeft="16dp" />
</LinearLayout>
</LinearLayout>
</RelativeLayout>

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="location_permission_notification_title">%s 需要位置权限</string>
<string name="location_permission_notification_content">请点击并选择 \"%s\" 去允许 %s 访问此设备的位置。</string>
<string name="location_permission_background_option_name">始终允许</string>
<string name="permission_hint">建议给microG服务授予"始终允许"权限缺少此权限可能将导致部分应用在后台运行时无法获取当前位置的问题如Microsoft Teams的位置分享功能可能会受影响。</string>
<string name="permission_hint_title">提示信息:</string>
<string name="permission_setting_hint_title">只需两步:</string>
<string name="open_settings">打开设置</string>
<string name="dont_remind_again">不再提醒</string>
<string name="rationale_foreground_permission">
请允许 <xliff:g example="microG">%1$s</xliff:g> 访问此设备的位置。
\n需要这些权限才能在支持应用程序的地图功能中启用位置。
</string>
<string name="rationale_permission">
请选择“始终允许”以允许 <xliff:g example="microG">%1$s</xliff:g> 访问此设备的位置。
</string>
<string name="rationale_background_permission">
请点击“权限管理器”并选择“始终允许”。
</string>
</resources>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2023 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="location_permission_notification_title">%s requires location permissions</string>
<string name="location_permission_notification_content">Please click and select \"%s\" to allow %s to access this device\'s location</string>
<string name="location_permission_background_option_name">Allow all the time</string>
<string name="permission_hint">It is recommended to grant the "Allow all the time" permission to the MicroG service. The lack of this permission may cause some APPs to be unable to obtain the current location when running in the background. For example, Microsoft Team location sharing function may be affected.</string>
<string name="permission_hint_title">Tip:</string>
<string name="permission_setting_hint_title">Two steps Only:</string>
<string name="open_settings">Open settings</string>
<string name="dont_remind_again">Don\'t remind again</string>
<string name="rationale_foreground_permission">
Please allow <xliff:g example="microG">%1$s</xliff:g> to access this device\'s location.
\nThe permissions are needed to enable location in map features of supporting apps.
</string>
<string name="rationale_permission">
Please select \"ALLOW ALL THE TIME\" to allow <xliff:g example="microG">%1$s</xliff:g> to access this device\'s location.
</string>
<string name="rationale_background_permission">
Please click on the \"Permission\u00A0Manager\" and select \"ALLOW ALL THE TIME\".
</string>
</resources>

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2020 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BODY_SENSORS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.UPDATE_DEVICE_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.UPDATE_APP_OPS_STATS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.WATCH_APPOPS"
tools:ignore="ProtectedPermissions" />
<application>
<activity
android:name="org.microg.gms.location.settings.LocationSettingsCheckerActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":ui"
android:launchMode="singleTask"
android:theme="@style/Theme.App.Translucent">
<intent-filter android:priority="-1">
<action android:name="com.google.android.gms.location.settings.CHECK_SETTINGS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="org.microg.gms.location.settings.GoogleLocationSettingsActivity"
android:excludeFromRecents="true"
android:exported="true"
android:launchMode="singleTask"
android:process=":ui"
android:theme="@style/Theme.App.Translucent">
<intent-filter>
<action android:name="com.google.android.location.settings.GOOGLE_LOCATION_SETTINGS" />
<action android:name="com.google.android.gms.location.settings.GOOGLE_LOCATION_SETTINGS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="org.microg.gms.location.manager.AskPermissionActivity"
android:excludeFromRecents="true"
android:process=":ui"
android:theme="@style/Theme.App.Translucent"
android:exported="false" />
<service
android:name="org.microg.gms.location.manager.LocationManagerService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.location.internal.GoogleLocationManagerService.START" />
</intent-filter>
</service>
<service
android:name="org.microg.gms.location.reporting.ReportingAndroidService"
android:exported="true">
<intent-filter>
<action android:name="com.google.android.location.reporting.service.START" />
<action android:name="com.google.android.gms.location.reporting.service.START" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
<receiver
android:name="org.microg.gms.location.ui.ConfigurationRequiredReceiver"
android:exported="false">
<intent-filter>
<action android:name="org.microg.gms.location.network.ACTION_CONFIGURATION_REQUIRED" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -0,0 +1,26 @@
/**
* SPDX-FileCopyrightText: 2025 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package com.google.android.location.settings
import android.accounts.Account
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class LocationHistorySettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (intent != null) {
val account = intent.extras?.getParcelable<Account>("account")
val settingIntent = Intent("com.google.android.gms.accountsettings.ACCOUNT_PREFERENCES_SETTINGS")
settingIntent.putExtra("extra.accountName", account?.name)
settingIntent.putExtra("extra.screenId", 227)
startActivity(settingIntent)
}
finish()
}
}

View file

@ -0,0 +1,268 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.app.PendingIntent
import android.location.Location
import android.os.IBinder
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.Status
import com.google.android.gms.common.api.internal.IStatusCallback
import com.google.android.gms.common.internal.ICancelToken
import com.google.android.gms.location.*
import com.google.android.gms.location.internal.*
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
abstract class AbstractLocationManagerInstance : IGoogleLocationManagerService.Stub() {
override fun requestActivityUpdates(detectionIntervalMillis: Long, triggerUpdates: Boolean, callbackIntent: PendingIntent) {
requestActivityUpdatesWithCallback(ActivityRecognitionRequest().apply {
intervalMillis = detectionIntervalMillis
triggerUpdate = triggerUpdates
}, callbackIntent, EmptyStatusCallback())
}
override fun getLocationAvailabilityWithPackage(packageName: String?): LocationAvailability {
val reference = AtomicReference(LocationAvailability.UNAVAILABLE)
val latch = CountDownLatch(1)
getLocationAvailabilityWithReceiver(LocationAvailabilityRequest(), LocationReceiver(object : ILocationAvailabilityStatusCallback.Stub() {
override fun onLocationAvailabilityStatus(status: Status, location: LocationAvailability) {
if (status.isSuccess) {
reference.set(location)
}
latch.countDown()
}
}))
return reference.get()
}
override fun getCurrentLocation(request: CurrentLocationRequest, callback: ILocationStatusCallback): ICancelToken {
return getCurrentLocationWithReceiver(request, LocationReceiver(callback))
}
// region Geofences
override fun addGeofencesList(geofences: List<ParcelableGeofence>, pendingIntent: PendingIntent, callbacks: IGeofencerCallbacks, packageName: String) {
val request = GeofencingRequest.Builder()
.addGeofences(geofences)
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER or GeofencingRequest.INITIAL_TRIGGER_DWELL)
.build()
addGeofences(request, pendingIntent, callbacks)
}
override fun addGeofencesWithCallback(request: GeofencingRequest?, pendingIntent: PendingIntent?, callback: IStatusCallback?) {
addGeofences(request, pendingIntent, object : IGeofencerCallbacks.Default() {
override fun onAddGeofenceResult(statusCode: Int, requestIds: Array<out String?>?) {
callback?.onResult(Status(statusCode, null))
}
})
}
override fun removeGeofencesWithCallback(request: RemoveGeofencingRequest?, callback: IStatusCallback?) {
removeGeofences(request, object : IGeofencerCallbacks.Default() {
override fun onRemoveGeofencesByRequestIdsResult(statusCode: Int, requestIds: Array<out String?>?) {
callback?.onResult(Status(statusCode, null))
}
override fun onRemoveGeofencesByPendingIntentResult(statusCode: Int, pendingIntent: PendingIntent?) {
callback?.onResult(Status(statusCode, null))
}
})
}
override fun removeGeofencesById(geofenceRequestIds: Array<out String>, callbacks: IGeofencerCallbacks?, packageName: String?) {
removeGeofences(RemoveGeofencingRequest.byGeofenceIds(geofenceRequestIds.toList()), callbacks)
}
override fun removeGeofencesByIntent(pendingIntent: PendingIntent, callbacks: IGeofencerCallbacks?, packageName: String?) {
removeGeofences(RemoveGeofencingRequest.byPendingIntent(pendingIntent), callbacks)
}
// endregion
// region Last location
override fun getLastLocation(): Location? {
val reference = AtomicReference<Location>()
val latch = CountDownLatch(1)
val request = LastLocationRequest.Builder().setMaxUpdateAgeMillis(Long.MAX_VALUE).setGranularity(Granularity.GRANULARITY_PERMISSION_LEVEL).build()
getLastLocationWithReceiver(request, LocationReceiver(object : ILocationStatusCallback.Stub() {
override fun onLocationStatus(status: Status, location: Location?) {
if (status.isSuccess) {
reference.set(location)
}
latch.countDown()
}
}))
if (latch.await(30, TimeUnit.SECONDS)) {
return reference.get()
}
return null
}
override fun getLastLocationWithRequest(request: LastLocationRequest, callback: ILocationStatusCallback) {
getLastLocationWithReceiver(request, LocationReceiver(callback))
}
override fun getLastLocationWithPackage(packageName: String?): Location? {
return lastLocation
}
override fun getLastLocationWith(s: String?): Location? {
return lastLocation
}
// endregion
// region Mock locations
override fun setMockMode(mockMode: Boolean) {
val latch = CountDownLatch(1)
setMockModeWithCallback(mockMode, object : IStatusCallback.Stub() {
override fun onResult(status: Status?) {
latch.countDown()
}
})
latch.await(30, TimeUnit.SECONDS)
}
override fun setMockLocation(mockLocation: Location) {
val latch = CountDownLatch(1)
setMockLocationWithCallback(mockLocation, object : IStatusCallback.Stub() {
override fun onResult(status: Status?) {
latch.countDown()
}
})
latch.await(30, TimeUnit.SECONDS)
}
// endregion
// region Location updates
abstract fun registerLocationUpdates(
oldBinder: IBinder?,
binder: IBinder,
callback: ILocationCallback,
request: LocationRequest,
statusCallback: IStatusCallback
)
abstract fun registerLocationUpdates(pendingIntent: PendingIntent, request: LocationRequest, statusCallback: IStatusCallback)
abstract fun unregisterLocationUpdates(binder: IBinder, statusCallback: IStatusCallback)
abstract fun unregisterLocationUpdates(pendingIntent: PendingIntent, statusCallback: IStatusCallback)
override fun requestLocationUpdatesWithCallback(receiver: LocationReceiver, request: LocationRequest, callback: IStatusCallback) {
when (receiver.type) {
LocationReceiver.TYPE_LISTENER -> registerLocationUpdates(
receiver.oldBinderReceiver,
receiver.binderReceiver!!,
receiver.listener.asCallback(),
request,
callback
)
LocationReceiver.TYPE_CALLBACK -> registerLocationUpdates(
receiver.oldBinderReceiver,
receiver.binderReceiver!!,
receiver.callback,
request,
callback
)
LocationReceiver.TYPE_PENDING_INTENT -> registerLocationUpdates(receiver.pendingIntentReceiver!!, request, callback)
else -> throw IllegalArgumentException("unknown location receiver type");
}
}
override fun removeLocationUpdatesWithCallback(receiver: LocationReceiver, callback: IStatusCallback) {
when (receiver.type) {
LocationReceiver.TYPE_LISTENER -> unregisterLocationUpdates(receiver.binderReceiver!!, callback)
LocationReceiver.TYPE_CALLBACK -> unregisterLocationUpdates(receiver.binderReceiver!!, callback)
LocationReceiver.TYPE_PENDING_INTENT -> unregisterLocationUpdates(receiver.pendingIntentReceiver!!, callback)
else -> throw IllegalArgumentException("unknown location receiver type");
}
}
override fun updateLocationRequest(data: LocationRequestUpdateData) {
val statusCallback = object : IStatusCallback.Stub() {
override fun onResult(status: Status) {
data.fusedLocationProviderCallback?.onFusedLocationProviderResult(FusedLocationProviderResult.create(status))
}
}
when (data.opCode) {
LocationRequestUpdateData.REQUEST_UPDATES -> {
when {
data.listener != null -> registerLocationUpdates(
null,
data.listener.asBinder(),
data.listener.asCallback().redirectCancel(data.fusedLocationProviderCallback),
data.request.request,
statusCallback
)
data.callback != null -> registerLocationUpdates(
null,
data.callback.asBinder(),
data.callback.redirectCancel(data.fusedLocationProviderCallback),
data.request.request,
statusCallback
)
data.pendingIntent != null -> registerLocationUpdates(data.pendingIntent, data.request.request, statusCallback)
}
}
LocationRequestUpdateData.REMOVE_UPDATES -> {
when {
data.listener != null -> unregisterLocationUpdates(data.listener.asBinder(), statusCallback)
data.callback != null -> unregisterLocationUpdates(data.callback.asBinder(), statusCallback)
data.pendingIntent != null -> unregisterLocationUpdates(data.pendingIntent, statusCallback)
}
}
else -> {
statusCallback.onResult(Status(CommonStatusCodes.ERROR, "invalid location request update operation: " + data.opCode))
}
}
}
override fun requestLocationUpdatesWithListener(request: LocationRequest, listener: ILocationListener) {
requestLocationUpdatesWithCallback(LocationReceiver(listener), request, EmptyStatusCallback())
}
override fun requestLocationUpdatesWithPackage(request: LocationRequest, listener: ILocationListener, packageName: String?) {
requestLocationUpdatesWithCallback(LocationReceiver(listener), request, EmptyStatusCallback())
}
override fun requestLocationUpdatesWithIntent(request: LocationRequest, callbackIntent: PendingIntent) {
requestLocationUpdatesWithCallback(LocationReceiver(callbackIntent), request, EmptyStatusCallback())
}
override fun requestLocationUpdatesInternalWithListener(request: LocationRequestInternal, listener: ILocationListener) {
requestLocationUpdatesWithCallback(LocationReceiver(listener), request.request, EmptyStatusCallback())
}
override fun requestLocationUpdatesInternalWithIntent(request: LocationRequestInternal, callbackIntent: PendingIntent) {
requestLocationUpdatesWithCallback(LocationReceiver(callbackIntent), request.request, EmptyStatusCallback())
}
override fun removeLocationUpdatesWithListener(listener: ILocationListener) {
removeLocationUpdatesWithCallback(LocationReceiver(listener), EmptyStatusCallback())
}
override fun removeLocationUpdatesWithIntent(callbackIntent: PendingIntent) {
removeLocationUpdatesWithCallback(LocationReceiver(callbackIntent), EmptyStatusCallback())
}
// endregion
class EmptyStatusCallback : IStatusCallback.Stub() {
override fun onResult(status: Status?) = Unit
}
}

View file

@ -0,0 +1,147 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.os.Message
import android.os.Messenger
import android.provider.Settings
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import org.microg.gms.location.core.BuildConfig
import org.microg.gms.location.core.BuildConfig.FORCE_SHOW_BACKGROUND_PERMISSION
const val EXTRA_MESSENGER = "messenger"
const val EXTRA_PERMISSIONS = "permissions"
const val EXTRA_GRANT_RESULTS = "results"
private const val REQUEST_CODE_PERMISSION = 120
private const val REQUEST_CODE_SETTINGS = 121
class AskPermissionActivity : AppCompatActivity() {
private var permissionGrants = IntArray(0)
private val permissionsFromIntent: Array<String>
get() = intent?.getStringArrayExtra(EXTRA_PERMISSIONS) ?: emptyArray()
private val permissionsToRequest: Array<String>
get() = permissionsFromIntent.let {
if (FORCE_SHOW_BACKGROUND_PERMISSION.isNotEmpty() && it.contains(ACCESS_BACKGROUND_LOCATION) && !it.contains(FORCE_SHOW_BACKGROUND_PERMISSION)) {
it + FORCE_SHOW_BACKGROUND_PERMISSION
} else {
it
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "AskPermissionActivity: onCreate")
requestPermissions()
}
private fun updatePermissionGrants() {
permissionGrants = permissionsFromIntent.map {
if (FORCE_SHOW_BACKGROUND_PERMISSION.isNotEmpty() && FORCE_SHOW_BACKGROUND_PERMISSION == it) {
PackageManager.PERMISSION_GRANTED
} else {
ContextCompat.checkSelfPermission(this, it)
}
}.toIntArray()
}
private fun requestPermissions() {
updatePermissionGrants()
if (permissionGrants.all { it == PackageManager.PERMISSION_GRANTED }) {
finishWithReply()
} else {
if (firstRequestLocationSettingsDialog) {
ActivityCompat.requestPermissions(this, permissionsToRequest, REQUEST_CODE_PERMISSION)
} else {
startActivityForResult(Intent().apply {
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS
data = Uri.fromParts("package", packageName, null)
}, REQUEST_CODE_SETTINGS)
}
}
}
private fun finishWithReply(code: Int = RESULT_OK) {
updatePermissionGrants()
val extras = bundleOf(EXTRA_GRANT_RESULTS to permissionGrants)
intent?.getParcelableExtra<Messenger>(EXTRA_MESSENGER)?.let {
runCatching {
it.send(Message.obtain().apply {
what = code
data = extras
})
}
}
setResult(code, Intent().apply { putExtras(extras) })
if (BuildConfig.SHOW_NOTIFICATION_WHEN_NOT_PERMITTED) {
updatePermissionGrants()
val clazz = runCatching { Class.forName("org.microg.gms.location.manager.AskPermissionNotificationActivity") }.getOrNull()
if (permissionGrants.any { it == PackageManager.PERMISSION_DENIED }) {
runCatching {
clazz?.getDeclaredMethod("showLocationPermissionNotification", Context::class.java)
?.invoke(null, this@AskPermissionActivity.applicationContext)
}
} else {
runCatching {
clazz?.getDeclaredMethod("hideLocationPermissionNotification", Context::class.java)
?.invoke(null, this@AskPermissionActivity.applicationContext)
}
}
}
finish()
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
if (requestCode == REQUEST_CODE_PERMISSION) {
Log.d(TAG, "onRequestPermissionsResult: permissions:${permissions.joinToString(",")} grantResults:${grantResults.joinToString(",")}")
if (SDK_INT >= 30) {
val backgroundRequested = permissions.contains(ACCESS_BACKGROUND_LOCATION)
if (FORCE_SHOW_BACKGROUND_PERMISSION.isNotEmpty() && permissions.contains(FORCE_SHOW_BACKGROUND_PERMISSION)) {
grantResults[permissions.indexOf(FORCE_SHOW_BACKGROUND_PERMISSION)] = PackageManager.PERMISSION_GRANTED
}
grantResults.forEach { Log.d(TAG, "onRequestPermissionsResult: $it") }
permissionGrants.forEach { Log.d(TAG, "onRequestPermissionsResult permissionGrants: $it") }
val backgroundDenied = backgroundRequested && grantResults[permissions.indexOf(ACCESS_BACKGROUND_LOCATION)] == PackageManager.PERMISSION_DENIED
val onlyBackgroundDenied = backgroundDenied && grantResults.count { it == PackageManager.PERMISSION_DENIED } == 1
val someAccepted = !permissionGrants.contentEquals(grantResults)
Log.d(TAG, "onRequestPermissionsResult onlyBackgroundDenied: $onlyBackgroundDenied someAccepted:$someAccepted")
if (onlyBackgroundDenied && someAccepted) {
// Only background denied, ask again as some systems require that
requestPermissions()
return
}
}
firstRequestLocationSettingsDialog = false
finishWithReply()
} else {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_SETTINGS) {
updatePermissionGrants()
finishWithReply()
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
companion object {
private var firstRequestLocationSettingsDialog: Boolean = true
}
}

View file

@ -0,0 +1,345 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.app.AppOpsManager
import android.content.Context
import android.hardware.GeomagneticField
import android.hardware.Sensor
import android.hardware.Sensor.*
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.hardware.SensorManager.*
import android.location.Location
import android.os.Build.VERSION.SDK_INT
import android.os.Handler
import android.os.HandlerThread
import android.os.IBinder
import android.os.SystemClock
import android.os.WorkSource
import android.util.Log
import android.view.Surface
import android.view.WindowManager
import androidx.annotation.GuardedBy
import androidx.core.content.getSystemService
import androidx.core.location.LocationCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.location.DeviceOrientation
import com.google.android.gms.location.DeviceOrientationRequest
import com.google.android.gms.location.IDeviceOrientationListener
import com.google.android.gms.location.Priority.PRIORITY_PASSIVE
import com.google.android.gms.location.internal.ClientIdentity
import com.google.android.gms.location.internal.DeviceOrientationRequestInternal
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.microg.gms.location.formatDuration
import org.microg.gms.utils.WorkSourceUtil
import java.io.PrintWriter
import kotlin.math.*
class DeviceOrientationManager(private val context: Context, override val lifecycle: Lifecycle, private val requestDetailsUpdatedCallback: () -> Unit) : LifecycleOwner, SensorEventListener, IBinder.DeathRecipient {
private var lock = Mutex(false)
private var started: Boolean = false
private var sensors: Set<Sensor>? = null
private var handlerThread: HandlerThread? = null
private val requests = mutableMapOf<IBinder, DeviceOrientationRequestHolder>()
private val appOpsLock = Any()
@GuardedBy("appOpsLock")
private var currentAppOps = emptySet<ClientIdentity>()
val isActive: Boolean
get() = requests.isNotEmpty()
suspend fun add(clientIdentity: ClientIdentity, request: DeviceOrientationRequestInternal, listener: IDeviceOrientationListener) {
listener.asBinder().linkToDeath(this, 0)
lock.withLock {
requests[listener.asBinder()] = DeviceOrientationRequestHolder(clientIdentity, request.request, listener)
updateStatus()
}
}
suspend fun remove(clientIdentity: ClientIdentity, listener: IDeviceOrientationListener) {
listener.asBinder().unlinkToDeath(this, 0)
lock.withLock {
requests.remove(listener.asBinder())
updateStatus()
}
}
private fun SensorManager.registerListener(sensor: Sensor, handler: Handler) {
if (SDK_INT >= 19) {
registerListener(this@DeviceOrientationManager, sensor, SAMPLING_PERIOD_US, MAX_REPORT_LATENCY_US, handler)
} else {
registerListener(this@DeviceOrientationManager, sensor, SAMPLING_PERIOD_US, handler)
}
}
private fun updateStatus() {
if (requests.isNotEmpty() && !started) {
try {
val sensorManager = context.getSystemService<SensorManager>() ?: return
val sensors = mutableSetOf<Sensor>()
if (SDK_INT >= 33) {
sensorManager.getDefaultSensor(TYPE_HEADING)?.let { sensors.add(it) }
}
if (sensors.isEmpty()) {
sensorManager.getDefaultSensor(TYPE_ROTATION_VECTOR)?.let { sensors.add(it) }
}
if (sensors.isEmpty()) {
sensors.add(sensorManager.getDefaultSensor(TYPE_MAGNETIC_FIELD) ?: return)
sensors.add(sensorManager.getDefaultSensor(TYPE_ACCELEROMETER) ?: return)
}
handlerThread = HandlerThread("DeviceOrientation")
handlerThread!!.start()
val handler = Handler(handlerThread!!.looper)
for (sensor in sensors) {
sensorManager.registerListener(sensor, handler)
}
this.sensors = sensors
started = true
} catch (e: Exception) {
Log.w(TAG, e)
}
} else if (requests.isEmpty() && started) {
stop()
}
requestDetailsUpdatedCallback()
updateAppOps()
}
private fun updateAppOps() {
synchronized(appOpsLock) {
val newAppOps = mutableSetOf<ClientIdentity>()
for (request in requests.values) {
if (request.clientIdentity.isSelfUser()) continue
newAppOps.add(request.clientIdentity)
}
Log.d(TAG, "Updating app ops for device orientation, change attribution to: ${newAppOps.map { it.packageName }.joinToString().takeIf { it.isNotEmpty() } ?: "none"}")
for (oldAppOp in currentAppOps) {
context.finishAppOp(AppOpsManager.OPSTR_MONITOR_LOCATION, oldAppOp)
}
for (newAppOp in newAppOps) {
context.startAppOp(AppOpsManager.OPSTR_MONITOR_LOCATION, newAppOp)
}
currentAppOps = newAppOps
}
}
override fun binderDied() {
lifecycleScope.launchWhenStarted {
val toRemove = requests.keys.filter { !it.isBinderAlive }.toList()
for (binder in toRemove) {
requests.remove(binder)
}
updateStatus()
}
}
private var location: Location? = null
fun onLocationChanged(location: Location) {
this.location = location
updateHeading()
}
private var accelerometerValues = FloatArray(3)
private var accelerometerRealtimeNanos = 0L
private fun handleAccelerometerEvent(event: SensorEvent) {
event.values.copyInto(accelerometerValues)
accelerometerRealtimeNanos = event.timestamp
updateAzimuth()
}
private var magneticFieldValues = FloatArray(3)
private var magneticRealtimeNanos = 0L
private fun handleMagneticEvent(event: SensorEvent) {
event.values.copyInto(magneticFieldValues)
magneticRealtimeNanos = event.timestamp
updateAzimuth()
}
private var azimuths = FloatArray(5)
private var azimuthIndex = 0
private var hadAzimuths = false
private var azimuth = Float.NaN
private var azimuthRealtimeNanos = 0L
private var azimuthAccuracy = Float.NaN
private fun updateAzimuth() {
if (accelerometerRealtimeNanos == 0L || magneticRealtimeNanos == 0L) return
var r = FloatArray(9)
val i = FloatArray(9)
if (getRotationMatrix(r, i, accelerometerValues, magneticFieldValues)) {
r = remapForOrientation(r)
val values = FloatArray(3)
getOrientation(r, values)
azimuths[azimuthIndex] = values[0]
if (azimuthIndex == azimuths.size - 1) {
azimuthIndex = 0
hadAzimuths = true
} else {
azimuthIndex++
}
var sumSin = 0.0
var sumCos = 0.0
for (j in 0 until (if (hadAzimuths) azimuths.size else azimuthIndex)) {
sumSin = sin(azimuths[j].toDouble())
sumCos = cos(azimuths[j].toDouble())
}
azimuth = Math.toDegrees(atan2(sumSin, sumCos)).toFloat()
azimuthRealtimeNanos = max(accelerometerRealtimeNanos, magneticRealtimeNanos)
updateHeading()
}
}
private fun remapForOrientation(r: FloatArray): FloatArray {
val display = context.getSystemService<WindowManager>()?.defaultDisplay
fun remap(x: Int, y: Int) = FloatArray(9).also { remapCoordinateSystem(r, x, y, it) }
return when (display?.rotation) {
Surface.ROTATION_90 -> remap(AXIS_Y, AXIS_MINUS_X)
Surface.ROTATION_180 -> remap(AXIS_MINUS_X, AXIS_MINUS_Y)
Surface.ROTATION_270 -> remap(AXIS_MINUS_Y, AXIS_X)
else -> r
}
}
private fun handleRotationVectorEvent(event: SensorEvent) {
val v = FloatArray(3)
event.values.copyInto(v, endIndex = 3)
var r = FloatArray(9)
getRotationMatrixFromVector(r, v)
r = remapForOrientation(r)
val values = FloatArray(3)
getOrientation(r, values)
azimuth = Math.toDegrees(values[0].toDouble()).toFloat()
azimuthRealtimeNanos = event.timestamp
if (SDK_INT >= 18 && values.size >= 5 && values[4] != -1f) {
azimuthAccuracy = Math.toDegrees(values[4].toDouble()).toFloat()
}
updateHeading()
}
private var heading = Float.NaN
private var headingAccuracy = Float.NaN
private var headingRealtimeNanos = 0L
private fun updateHeading() {
if (!azimuth.isNaN()) {
if (location == null) {
heading = azimuth
headingAccuracy = azimuthAccuracy.takeIf { !it.isNaN() } ?: 90.0f
headingRealtimeNanos = azimuthRealtimeNanos
} else {
heading = azimuth + location!!.run { GeomagneticField(latitude.toFloat(), longitude.toFloat(), altitude.toFloat(), time).declination }
headingAccuracy = azimuthAccuracy.takeIf { !it.isNaN() } ?: 45.0f
headingRealtimeNanos = max(LocationCompat.getElapsedRealtimeNanos(location!!), azimuthRealtimeNanos)
}
updateDeviceOrientation()
}
}
private fun handleHeadingEvent(event: SensorEvent) {
heading = event.values[0]
headingAccuracy = event.values[1]
headingRealtimeNanos = event.timestamp
updateDeviceOrientation()
}
private fun updateDeviceOrientation() {
val deviceOrientation = DeviceOrientation()
deviceOrientation.headingDegrees = heading
deviceOrientation.headingErrorDegrees = headingAccuracy
deviceOrientation.elapsedRealtimeNanos = headingRealtimeNanos
lifecycleScope.launchWhenStarted {
processNewDeviceOrientation(deviceOrientation)
}
}
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
TYPE_ACCELEROMETER -> handleAccelerometerEvent(event)
TYPE_MAGNETIC_FIELD -> handleMagneticEvent(event)
TYPE_ROTATION_VECTOR -> handleRotationVectorEvent(event)
TYPE_HEADING -> handleHeadingEvent(event)
else -> return
}
}
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
if (sensor.type == TYPE_ROTATION_VECTOR) {
azimuthAccuracy = when (accuracy) {
SENSOR_STATUS_ACCURACY_LOW -> 45.0f
SENSOR_STATUS_ACCURACY_MEDIUM -> 30.0f
SENSOR_STATUS_ACCURACY_HIGH -> 15.0f
else -> Float.NaN
}
}
}
fun stop() {
if (SDK_INT >= 18) handlerThread?.looper?.quitSafely()
else handlerThread?.looper?.quit()
context.getSystemService<SensorManager>()?.unregisterListener(this)
started = false
}
fun dump(writer: PrintWriter) {
writer.println("Current device orientation request (started=$started, sensors=${sensors?.map { it.name }})")
for (request in requests.values.toList()) {
writer.println("- ${request.workSource} (pending: ${request.updatesPending.let { if (it == Int.MAX_VALUE) "\u221e" else "$it" }} ${request.timePendingMillis.formatDuration()}, app-op: ${currentAppOps.contains(request.clientIdentity)})")
}
}
suspend fun processNewDeviceOrientation(deviceOrientation: DeviceOrientation) {
lock.withLock {
val toRemove = mutableSetOf<IBinder>()
for ((binder, holder) in requests) {
try {
holder.processNewDeviceOrientation(deviceOrientation)
} catch (e: Exception) {
toRemove.add(binder)
}
}
for (binder in toRemove) {
requests.remove(binder)
}
if (toRemove.isNotEmpty()) {
updateStatus()
}
}
}
companion object {
const val SAMPLING_PERIOD_US = 20_000
const val MAX_REPORT_LATENCY_US = 200_000
private class DeviceOrientationRequestHolder(
val clientIdentity: ClientIdentity,
private val request: DeviceOrientationRequest,
private val listener: IDeviceOrientationListener,
) {
private var updates = 0
private var lastOrientation: DeviceOrientation? = null
val updatesPending: Int
get() = request.numUpdates - updates
val timePendingMillis: Long
get() = request.expirationTime - SystemClock.elapsedRealtime()
val workSource = WorkSource().also { WorkSourceUtil.add(it, clientIdentity.uid, clientIdentity.packageName) }
fun processNewDeviceOrientation(deviceOrientation: DeviceOrientation) {
if (timePendingMillis < 0) throw RuntimeException("duration limit reached (expired at ${request.expirationTime}, now is ${SystemClock.elapsedRealtime()})")
if (lastOrientation != null && abs(lastOrientation!!.headingDegrees - deviceOrientation.headingDegrees) < Math.toDegrees(request.smallestAngleChangeRadians.toDouble())) return
if (lastOrientation == deviceOrientation) return
listener.onDeviceOrientationChanged(deviceOrientation)
if (request.numUpdates != Int.MAX_VALUE) updates++
if (updatesPending <= 0) throw RuntimeException("max updates reached")
}
}
}
}

View file

@ -0,0 +1,162 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.content.Context
import android.location.Location
import android.location.LocationManager
import android.os.SystemClock
import android.util.Log
import androidx.core.content.getSystemService
import androidx.core.location.LocationCompat
import com.google.android.gms.common.internal.safeparcel.SafeParcelable.Field
import com.google.android.gms.common.internal.safeparcel.SafeParcelableSerializer
import com.google.android.gms.location.Granularity
import com.google.android.gms.location.Granularity.GRANULARITY_COARSE
import com.google.android.gms.location.Granularity.GRANULARITY_FINE
import com.google.android.gms.location.LocationAvailability
import org.microg.gms.location.elapsedMillis
import org.microg.safeparcel.AutoSafeParcelable
import java.io.File
import java.lang.Long.max
import java.util.concurrent.TimeUnit
import kotlin.math.min
class LastLocationCapsule(private val context: Context) {
private var lastFineLocation: Location? = null
private var lastCoarseLocation: Location? = null
private var lastFineLocationTimeCoarsed: Location? = null
private var lastCoarseLocationTimeCoarsed: Location? = null
var locationAvailability: LocationAvailability = LocationAvailability.AVAILABLE
private val file: File
get() = context.getFileStreamPath(FILE_NAME)
fun getLocation(effectiveGranularity: @Granularity Int, maxUpdateAgeMillis: Long = Long.MAX_VALUE): Location? {
val location = when (effectiveGranularity) {
GRANULARITY_COARSE -> lastCoarseLocationTimeCoarsed
GRANULARITY_FINE -> lastCoarseLocation
else -> return null
} ?: return null
val cliff = if (effectiveGranularity == GRANULARITY_COARSE) max(maxUpdateAgeMillis, TIME_COARSE_CLIFF) else maxUpdateAgeMillis
val elapsedRealtimeDiff = SystemClock.elapsedRealtime() - location.elapsedMillis
if (elapsedRealtimeDiff > cliff) return null
if (elapsedRealtimeDiff <= maxUpdateAgeMillis) return location
// Location is too old according to maxUpdateAgeMillis, but still in scope due to time coarsing. Adjust time
val locationUpdated = Location(location)
val timeAdjustment = elapsedRealtimeDiff - maxUpdateAgeMillis
locationUpdated.elapsedRealtimeNanos = location.elapsedRealtimeNanos + TimeUnit.MILLISECONDS.toNanos(timeAdjustment)
locationUpdated.time = location.time + timeAdjustment
return locationUpdated
}
fun reset() {
lastFineLocation = null
lastFineLocationTimeCoarsed = null
lastCoarseLocation = null
lastCoarseLocationTimeCoarsed = null
locationAvailability = LocationAvailability.AVAILABLE
}
fun updateCoarseLocation(location: Location) {
location.elapsedRealtimeNanos = min(location.elapsedRealtimeNanos, SystemClock.elapsedRealtimeNanos())
location.time = min(location.time, System.currentTimeMillis())
if (lastCoarseLocation != null && lastCoarseLocation!!.elapsedMillis + EXTENSION_CLIFF > location.elapsedMillis) {
if (!location.hasSpeed()) {
location.speed = lastCoarseLocation!!.distanceTo(location) / ((location.elapsedMillis - lastCoarseLocation!!.elapsedMillis) / 1000)
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed)
}
if (!location.hasBearing() && location.speed > 0.5f) {
location.bearing = lastCoarseLocation!!.bearingTo(location)
LocationCompat.setBearingAccuracyDegrees(location, 180.0f)
}
}
lastCoarseLocation = newest(lastCoarseLocation, location)
lastCoarseLocationTimeCoarsed = newest(lastCoarseLocationTimeCoarsed, location, TIME_COARSE_CLIFF)
}
fun updateFineLocation(location: Location) {
location.elapsedRealtimeNanos = min(location.elapsedRealtimeNanos, SystemClock.elapsedRealtimeNanos())
location.time = min(location.time, System.currentTimeMillis())
lastFineLocation = newest(lastFineLocation, location)
lastFineLocationTimeCoarsed = newest(lastFineLocationTimeCoarsed, location, TIME_COARSE_CLIFF)
updateCoarseLocation(location)
}
private fun newest(oldLocation: Location?, newLocation: Location, cliff: Long = 0): Location {
if (oldLocation == null) return newLocation
if (LocationCompat.isMock(oldLocation) && !LocationCompat.isMock(newLocation)) return newLocation
oldLocation.elapsedRealtimeNanos = min(oldLocation.elapsedRealtimeNanos, SystemClock.elapsedRealtimeNanos())
oldLocation.time = min(oldLocation.time, System.currentTimeMillis())
if (newLocation.elapsedRealtimeNanos >= oldLocation.elapsedRealtimeNanos + TimeUnit.MILLISECONDS.toNanos(cliff)) return newLocation
return oldLocation
}
fun start() {
fun Location.adjustRealtime() = apply {
time = min(time, System.currentTimeMillis())
elapsedRealtimeNanos = min(
SystemClock.elapsedRealtimeNanos() - TimeUnit.MILLISECONDS.toNanos((System.currentTimeMillis() - time)),
SystemClock.elapsedRealtimeNanos()
)
}
try {
if (file.exists()) {
val capsule = SafeParcelableSerializer.deserializeFromBytes(file.readBytes(), LastLocationCapsuleParcelable.CREATOR)
lastFineLocation = capsule.lastFineLocation?.adjustRealtime()
lastCoarseLocation = capsule.lastCoarseLocation?.adjustRealtime()
lastFineLocationTimeCoarsed = capsule.lastFineLocationTimeCoarsed?.adjustRealtime()
lastCoarseLocationTimeCoarsed = capsule.lastCoarseLocationTimeCoarsed?.adjustRealtime()
}
} catch (e: Exception) {
Log.w(TAG, e)
// Ignore
}
fetchFromSystem()
}
fun fetchFromSystem() {
val locationManager = context.getSystemService<LocationManager>() ?: return
try {
locationManager.getLastKnownLocation(LocationManager.NETWORK_PROVIDER)?.let { updateCoarseLocation(it) }
locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)?.let { updateFineLocation(it) }
} catch (e: SecurityException) {
// Ignore
}
}
fun stop() {
try {
if (file.exists()) file.delete()
file.writeBytes(SafeParcelableSerializer.serializeToBytes(LastLocationCapsuleParcelable(lastFineLocation, lastCoarseLocation, lastFineLocationTimeCoarsed, lastCoarseLocationTimeCoarsed)))
} catch (e: Exception) {
Log.w(TAG, e)
// Ignore
}
}
companion object {
private const val FILE_NAME = "last_location_capsule"
private const val TIME_COARSE_CLIFF = 60_000L
private const val EXTENSION_CLIFF = 30_000L
private class LastLocationCapsuleParcelable(
@Field(1) @JvmField val lastFineLocation: Location?,
@Field(2) @JvmField val lastCoarseLocation: Location?,
@Field(3) @JvmField val lastFineLocationTimeCoarsed: Location?,
@Field(4) @JvmField val lastCoarseLocationTimeCoarsed: Location?
) : AutoSafeParcelable() {
constructor() : this(null, null, null, null)
companion object {
@JvmField
val CREATOR = AutoCreator(LastLocationCapsuleParcelable::class.java)
}
}
}
}

View file

@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.location.Location
import android.util.Log
import androidx.core.content.contentValuesOf
import androidx.core.database.getIntOrNull
class LocationAppsDatabase(context: Context) : SQLiteOpenHelper(context, "geoapps.db", null, 2) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_APPS($FIELD_PACKAGE TEXT NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_FORCE_COARSE INTEGER);")
db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_APPS_LAST_LOCATION($FIELD_PACKAGE TEXT NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_LATITUDE REAL NOT NULL, $FIELD_LONGITUDE REAL NOT NULL, $FIELD_ACCURACY REAL NOT NULL, $FIELD_PROVIDER TEXT NOT NULL);")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE_APPS}_index ON ${TABLE_APPS}(${FIELD_PACKAGE});")
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE_APPS_LAST_LOCATION}_index ON ${TABLE_APPS_LAST_LOCATION}(${FIELD_PACKAGE});")
}
private fun insertOrUpdateApp(packageName: String, vararg pairs: Pair<String, Any?>) {
val values = contentValuesOf(FIELD_PACKAGE to packageName, *pairs)
if (writableDatabase.insertWithOnConflict(TABLE_APPS, null, values, SQLiteDatabase.CONFLICT_IGNORE) < 0) {
writableDatabase.update(TABLE_APPS, values, "$FIELD_PACKAGE = ?", arrayOf(packageName))
}
close()
}
fun noteAppUsage(packageName: String) {
insertOrUpdateApp(packageName, FIELD_TIME to System.currentTimeMillis())
}
fun getForceCoarse(packageName: String): Boolean {
return readableDatabase.query(TABLE_APPS, arrayOf(FIELD_FORCE_COARSE), "$FIELD_PACKAGE = ?", arrayOf(packageName), null, null, null, "1").run {
try {
if (moveToNext()) {
getIntOrNull(0) == 1
} else {
false
}
} finally {
close()
}
}
}
fun setForceCoarse(packageName: String, forceCoarse: Boolean) {
insertOrUpdateApp(packageName, FIELD_FORCE_COARSE to (if (forceCoarse) 1 else 0))
}
fun noteAppLocation(packageName: String, location: Location?) {
noteAppUsage(packageName)
if (location == null) return
val values = contentValuesOf(
FIELD_PACKAGE to packageName,
FIELD_TIME to location.time,
FIELD_LATITUDE to location.latitude,
FIELD_LONGITUDE to location.longitude,
FIELD_ACCURACY to location.accuracy,
FIELD_PROVIDER to location.provider
)
writableDatabase.insertWithOnConflict(TABLE_APPS_LAST_LOCATION, null, values, SQLiteDatabase.CONFLICT_REPLACE)
close()
}
fun listAppsByAccessTime(limit: Int = Int.MAX_VALUE): List<Pair<String, Long>> {
val res = arrayListOf<Pair<String, Long>>()
readableDatabase.query(TABLE_APPS, arrayOf(FIELD_PACKAGE, FIELD_TIME), null, null, null, null, "$FIELD_TIME DESC", "$limit").apply {
while (moveToNext()) {
res.add(getString(0) to getLong(1))
}
close()
}
return res
}
fun getAppLocation(packageName: String): Location? {
return readableDatabase.query(
TABLE_APPS_LAST_LOCATION,
arrayOf(FIELD_LATITUDE, FIELD_LONGITUDE, FIELD_ACCURACY, FIELD_TIME, FIELD_PROVIDER),
"$FIELD_PACKAGE = ?",
arrayOf(packageName),
null,
null,
null,
"1"
).run {
try {
if (moveToNext()) {
Location(getString(4)).also {
it.latitude = getDouble(0)
it.longitude = getDouble(1)
it.accuracy = getFloat(2)
it.time = getLong(3)
}
} else {
null
}
} finally {
close()
}
}
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
onCreate(db)
if (oldVersion < 2) {
try {
db.execSQL("ALTER TABLE $TABLE_APPS ADD COLUMN IF NOT EXISTS $FIELD_FORCE_COARSE INTEGER;")
} catch (ignored: Exception) {
// Ignoring
}
}
}
companion object {
private const val TABLE_APPS = "apps"
private const val TABLE_APPS_LAST_LOCATION = "app_location"
private const val FIELD_PACKAGE = "package"
private const val FIELD_FORCE_COARSE = "force_coarse"
private const val FIELD_LATITUDE = "lat"
private const val FIELD_LONGITUDE = "lon"
private const val FIELD_ACCURACY = "acc"
private const val FIELD_TIME = "time"
private const val FIELD_PROVIDER = "provider"
}
}

View file

@ -0,0 +1,394 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.Manifest
import android.app.Activity
import android.app.PendingIntent
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationManager.*
import android.os.*
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import androidx.core.location.LocationRequestCompat.*
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.location.*
import com.google.android.gms.location.Granularity.GRANULARITY_COARSE
import com.google.android.gms.location.Granularity.GRANULARITY_FINE
import com.google.android.gms.location.Priority.PRIORITY_HIGH_ACCURACY
import com.google.android.gms.location.Priority.PRIORITY_PASSIVE
import com.google.android.gms.location.internal.ClientIdentity
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.microg.gms.location.*
import org.microg.gms.location.core.BuildConfig
import org.microg.gms.utils.IntentCacheManager
import java.io.PrintWriter
import kotlin.math.max
import kotlin.math.min
import android.location.LocationManager as SystemLocationManager
class LocationManager(private val context: Context, override val lifecycle: Lifecycle) : LifecycleOwner {
private var coarsePendingIntent: PendingIntent? = null
private val postProcessor by lazy { LocationPostProcessor() }
private val lastLocationCapsule by lazy { LastLocationCapsule(context) }
val database by lazy { LocationAppsDatabase(context) }
private val requestManager by lazy { LocationRequestManager(context, lifecycle, postProcessor, database) { updateLocationRequests() } }
private val gpsLocationListener by lazy { LocationListenerCompat { updateGpsLocation(it) } }
private val networkLocationListener by lazy { LocationListenerCompat { updateNetworkLocation(it) } }
private val settings by lazy { LocationSettings(context) }
private var boundToSystemNetworkLocation: Boolean = false
private val activePermissionRequestLock = Mutex()
private var activePermissionRequest: Deferred<Boolean>? = null
private var lastGpsLocation: Location? = null
private var lastNetworkLocation: Location? = null
private var currentGpsInterval: Long = -1
private var currentNetworkInterval: Long = -1
val deviceOrientationManager = DeviceOrientationManager(context, lifecycle) { updateLocationRequests() }
var started: Boolean = false
private set
suspend fun getLastLocation(clientIdentity: ClientIdentity, request: LastLocationRequest): Location? {
if (request.maxUpdateAgeMillis < 0) throw IllegalArgumentException()
GranularityUtil.checkValidGranularity(request.granularity)
if (request.isBypass) {
val permission = if (SDK_INT >= 33) "android.permission.LOCATION_BYPASS" else Manifest.permission.WRITE_SECURE_SETTINGS
if (context.checkPermission(permission, clientIdentity.pid, clientIdentity.uid) != PackageManager.PERMISSION_GRANTED) {
throw SecurityException("Caller must hold $permission for location bypass")
}
}
if (request.impersonation != null) {
Log.w(TAG, "${clientIdentity.packageName} wants to impersonate ${request.impersonation!!.packageName}. Ignoring.")
}
val permissionGranularity = context.granularityFromPermission(clientIdentity)
var effectiveGranularity = getEffectiveGranularity(request.granularity, permissionGranularity)
if (effectiveGranularity == GRANULARITY_FINE && database.getForceCoarse(clientIdentity.packageName) && !clientIdentity.isSelfUser()) effectiveGranularity = GRANULARITY_COARSE
val returnedLocation = if (effectiveGranularity > permissionGranularity) {
// No last location available at requested granularity due to lack of permission
null
} else {
ensurePermissions()
val preLocation = lastLocationCapsule.getLocation(effectiveGranularity, request.maxUpdateAgeMillis)
val processedLocation = postProcessor.process(preLocation, effectiveGranularity, clientIdentity.isGoogle(context))
if (!context.noteAppOpForEffectiveGranularity(clientIdentity, effectiveGranularity)) {
// App Op denied
null
} else if (processedLocation != null && clientIdentity.isSelfProcess()) {
// When the request is coming from us, we want to make sure to return a new object to not accidentally modify the internal state
Location(processedLocation)
} else {
processedLocation
}
}
if (!clientIdentity.isSelfUser()) database.noteAppLocation(clientIdentity.packageName, returnedLocation)
return returnedLocation?.let { Location(it).apply { provider = "fused" } }
}
fun getLocationAvailability(clientIdentity: ClientIdentity, request: LocationAvailabilityRequest): LocationAvailability {
if (request.bypass) {
val permission = if (SDK_INT >= 33) "android.permission.LOCATION_BYPASS" else Manifest.permission.WRITE_SECURE_SETTINGS
if (context.checkPermission(permission, clientIdentity.pid, clientIdentity.uid) != PackageManager.PERMISSION_GRANTED) {
throw SecurityException("Caller must hold $permission for location bypass")
}
}
if (request.impersonation != null) {
Log.w(TAG, "${clientIdentity.packageName} wants to impersonate ${request.impersonation!!.packageName}. Ignoring.")
}
return lastLocationCapsule.locationAvailability
}
suspend fun addBinderRequest(clientIdentity: ClientIdentity, binder: IBinder, callback: ILocationCallback, request: LocationRequest) {
updateBinderRequest(clientIdentity, null, binder, callback, request)
}
suspend fun updateBinderRequest(
clientIdentity: ClientIdentity,
oldBinder: IBinder?,
binder: IBinder,
callback: ILocationCallback,
request: LocationRequest
) {
Log.d(TAG, "updateBinderRequest $clientIdentity $request")
request.verify(context, clientIdentity)
val new = requestManager.update(oldBinder, binder, clientIdentity, callback, request, lastLocationCapsule)
if (new) {
val permissionsChanged = ensurePermissions()
if (permissionsChanged) {
updateLocationRequests()
}
}
}
suspend fun removeBinderRequest(binder: IBinder) {
requestManager.remove(binder)
}
suspend fun addIntentRequest(clientIdentity: ClientIdentity, pendingIntent: PendingIntent, request: LocationRequest) {
request.verify(context, clientIdentity)
ensurePermissions()
requestManager.add(pendingIntent, clientIdentity, request, lastLocationCapsule)
}
suspend fun removeIntentRequest(pendingIntent: PendingIntent) {
requestManager.remove(pendingIntent)
}
fun start() {
synchronized(this) {
if (started) return
started = true
}
val intent = Intent(context, LocationManagerService::class.java)
intent.action = LocationManagerService.ACTION_REPORT_LOCATION
coarsePendingIntent = PendingIntentCompat.getService(context, 0, intent, FLAG_UPDATE_CURRENT, true)
lastLocationCapsule.start()
requestManager.start()
}
fun stop() {
synchronized(this) {
if (!started) return
started = false
}
requestManager.stop()
lastLocationCapsule.stop()
deviceOrientationManager.stop()
if (context.hasNetworkLocationServiceBuiltIn()) {
val intent = Intent(ACTION_NETWORK_LOCATION_SERVICE)
intent.`package` = context.packageName
intent.putExtra(EXTRA_PENDING_INTENT, coarsePendingIntent)
intent.putExtra(EXTRA_ENABLE, false)
context.startService(intent)
}
val locationManager = context.getSystemService<SystemLocationManager>() ?: return
try {
if (boundToSystemNetworkLocation) {
LocationManagerCompat.removeUpdates(locationManager, networkLocationListener)
boundToSystemNetworkLocation = false
}
LocationManagerCompat.removeUpdates(locationManager, gpsLocationListener)
} catch (e: SecurityException) {
// Ignore
}
}
private fun updateLocationRequests() {
val gpsInterval = when {
deviceOrientationManager.isActive -> min(requestManager.intervalMillis, DEVICE_ORIENTATION_INTERVAL)
requestManager.priority == PRIORITY_HIGH_ACCURACY && requestManager.granularity == GRANULARITY_FINE -> requestManager.intervalMillis
else -> Long.MAX_VALUE
}
val networkInterval = when {
gpsInterval != Long.MAX_VALUE &&
(lastGpsLocation?.accuracy ?: Float.POSITIVE_INFINITY) <= NETWORK_OFF_GPS_ACCURACY &&
(lastGpsLocation?.elapsedMillis ?: 0) > SystemClock.elapsedRealtime() - NETWORK_OFF_GPS_AGE -> Long.MAX_VALUE
requestManager.priority < PRIORITY_PASSIVE && requestManager.granularity == GRANULARITY_COARSE -> max(requestManager.intervalMillis, MAX_COARSE_UPDATE_INTERVAL)
requestManager.priority < PRIORITY_PASSIVE && requestManager.granularity == GRANULARITY_FINE -> max(requestManager.intervalMillis, MAX_FINE_UPDATE_INTERVAL)
deviceOrientationManager.isActive -> DEVICE_ORIENTATION_INTERVAL
else -> Long.MAX_VALUE
}
val lowPower = requestManager.granularity <= GRANULARITY_COARSE || requestManager.priority >= Priority.PRIORITY_LOW_POWER || (requestManager.priority >= Priority.PRIORITY_BALANCED_POWER_ACCURACY && requestManager.intervalMillis >= BALANCE_LOW_POWER_INTERVAL)
if (context.hasNetworkLocationServiceBuiltIn() && currentNetworkInterval != networkInterval) {
val intent = Intent(ACTION_NETWORK_LOCATION_SERVICE)
intent.`package` = context.packageName
intent.putExtra(EXTRA_PENDING_INTENT, coarsePendingIntent)
intent.putExtra(EXTRA_ENABLE, networkInterval != Long.MAX_VALUE)
intent.putExtra(EXTRA_INTERVAL_MILLIS, networkInterval)
intent.putExtra(EXTRA_LOW_POWER, lowPower)
intent.putExtra(EXTRA_WORK_SOURCE, requestManager.workSource)
context.startService(intent)
currentNetworkInterval = networkInterval
}
val locationManager = context.getSystemService<SystemLocationManager>() ?: return
if (gpsInterval != currentGpsInterval) {
if (gpsInterval == Long.MAX_VALUE) {
// Fetch last location from GPS, just to make sure we already considered it
try {
val newGpsLocation = locationManager.getLastKnownLocation(GPS_PROVIDER)
if (newGpsLocation != null && newGpsLocation.elapsedMillis > (lastGpsLocation?.elapsedMillis ?: 0)) {
updateGpsLocation(newGpsLocation)
}
} catch (e: SecurityException) {
// Ignore
}
}
try {
locationManager.requestSystemProviderUpdates(GPS_PROVIDER, gpsInterval, QUALITY_HIGH_ACCURACY, gpsLocationListener)
currentGpsInterval = gpsInterval
} catch (e: Exception) {
// Ignore
}
}
if (!context.hasNetworkLocationServiceBuiltIn() && LocationManagerCompat.hasProvider(locationManager, NETWORK_PROVIDER) && currentNetworkInterval != networkInterval) {
boundToSystemNetworkLocation = true
if (networkInterval == Long.MAX_VALUE) {
// Fetch last location from GPS, just to make sure we already considered it
try {
locationManager.getLastKnownLocation(NETWORK_PROVIDER)?.let { updateNetworkLocation(it) }
} catch (e: SecurityException) {
// Ignore
}
}
try {
val quality = if (lowPower) QUALITY_LOW_POWER else QUALITY_BALANCED_POWER_ACCURACY
locationManager.requestSystemProviderUpdates(NETWORK_PROVIDER, networkInterval, quality, networkLocationListener)
currentNetworkInterval = networkInterval
} catch (e: Exception) {
// Ignore
}
}
}
private fun SystemLocationManager.requestSystemProviderUpdates(provider: String, interval: Long, @Quality quality: Int, listener: LocationListenerCompat) {
try {
if (interval != Long.MAX_VALUE) {
Log.d(TAG, "Request updates for $provider at interval ${interval}ms")
LocationManagerCompat.requestLocationUpdates(this, provider, Builder(interval).setQuality(quality).build(), listener, context.mainLooper)
} else {
Log.d(TAG, "Remove updates for $provider")
LocationManagerCompat.removeUpdates(this, listener)
}
} catch (e: SecurityException) {
throw RuntimeException(e)
} catch (e: Exception) {
throw RuntimeException(e)
}
}
fun updateNetworkLocation(location: Location) {
val lastLocation = lastLocationCapsule.getLocation(GRANULARITY_FINE)
// Ignore outdated location
if (lastLocation != null && location.elapsedMillis + UPDATE_CLIFF_MS < lastLocation.elapsedMillis) return
if (lastLocation == null ||
lastLocation.accuracy > location.accuracy ||
lastLocation.elapsedMillis + min(requestManager.intervalMillis * 2, UPDATE_CLIFF_MS) < location.elapsedMillis ||
lastLocation.accuracy + ((location.elapsedMillis - lastLocation.elapsedMillis) / 1000.0) > location.accuracy
) {
lastLocationCapsule.updateCoarseLocation(location)
sendNewLocation()
}
}
private fun updateGpsLocation(location: Location) {
if (location.provider != GPS_PROVIDER) return
lastGpsLocation = location
lastLocationCapsule.updateFineLocation(location)
sendNewLocation()
updateLocationRequests()
}
private fun sendNewLocation() {
lifecycleScope.launchWhenStarted {
requestManager.processNewLocation(lastLocationCapsule)
}
lastLocationCapsule.getLocation(GRANULARITY_FINE)?.let { deviceOrientationManager.onLocationChanged(it) }
}
/**
* @return `true` if permissions changed
*/
private suspend fun ensurePermissions(): Boolean {
if (SDK_INT < 23)
return false
val permissions = mutableListOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION)
if (SDK_INT >= 29) permissions.add(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
if (permissions.all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED })
return false
if (BuildConfig.FORCE_SHOW_BACKGROUND_PERMISSION.isNotEmpty()) permissions.add(BuildConfig.FORCE_SHOW_BACKGROUND_PERMISSION)
return requestPermission(permissions)
}
private suspend fun requestPermission(permissions: List<String>): Boolean {
val (completable, deferred) = activePermissionRequestLock.withLock {
if (activePermissionRequest == null) {
val completable = CompletableDeferred<Boolean>()
activePermissionRequest = completable
completable to activePermissionRequest!!
} else {
null to activePermissionRequest!!
}
}
if (completable != null) {
val intent = Intent(context, AskPermissionActivity::class.java)
intent.putExtra(EXTRA_MESSENGER, Messenger(object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
if (msg.what == Activity.RESULT_OK) {
lastLocationCapsule.fetchFromSystem()
updateLocationRequests()
val grantResults = msg.data?.getIntArray(EXTRA_GRANT_RESULTS) ?: IntArray(0)
completable.complete(grantResults.size == permissions.size && grantResults.all { it == PackageManager.PERMISSION_GRANTED })
} else {
completable.complete(false)
}
}
}))
intent.putExtra(EXTRA_PERMISSIONS, permissions.toTypedArray())
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
context.startActivity(intent)
Log.d(TAG, "Started AskPermissionActivity")
}
return deferred.await()
}
fun dump(writer: PrintWriter) {
writer.println("Location availability: ${lastLocationCapsule.locationAvailability}")
writer.println("Last coarse location: ${postProcessor.process(lastLocationCapsule.getLocation(GRANULARITY_COARSE), GRANULARITY_COARSE, true)}")
writer.println("Last fine location: ${postProcessor.process(lastLocationCapsule.getLocation(GRANULARITY_FINE), GRANULARITY_FINE, true)}")
writer.println("Interval: gps=${if (currentGpsInterval==Long.MAX_VALUE) "off" else currentGpsInterval.formatDuration()} network=${if (currentNetworkInterval==Long.MAX_VALUE) "off" else currentNetworkInterval.formatDuration()}")
writer.println("Network location: built-in=${context.hasNetworkLocationServiceBuiltIn()} system=$boundToSystemNetworkLocation")
requestManager.dump(writer)
deviceOrientationManager.dump(writer)
}
fun handleCacheIntent(intent: Intent) {
when (IntentCacheManager.getType(intent)) {
LocationRequestManager.CACHE_TYPE -> {
requestManager.handleCacheIntent(intent)
}
else -> {
Log.w(TAG, "Unknown cache intent: $intent")
}
}
}
companion object {
const val MAX_COARSE_UPDATE_INTERVAL = 20_000L
const val MAX_FINE_UPDATE_INTERVAL = 10_000L
const val EXTENSION_CLIFF_MS = 10_000L
const val UPDATE_CLIFF_MS = 30_000L
const val DEVICE_ORIENTATION_INTERVAL = 10_000L
const val NETWORK_OFF_GPS_AGE = 5000L
const val NETWORK_OFF_GPS_ACCURACY = 10f
const val BALANCE_LOW_POWER_INTERVAL = 30_000L
}
}

View file

@ -0,0 +1,420 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.Manifest.permission.*
import android.app.PendingIntent
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.location.Location
import android.location.LocationManager.GPS_PROVIDER
import android.location.LocationManager.NETWORK_PROVIDER
import android.os.Binder
import android.os.Build.VERSION.SDK_INT
import android.os.IBinder
import android.os.Parcel
import android.os.SystemClock
import android.provider.Settings
import android.util.Log
import androidx.core.app.PendingIntentCompat
import androidx.core.content.getSystemService
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.api.Status
import com.google.android.gms.common.api.internal.IStatusCallback
import com.google.android.gms.common.internal.ICancelToken
import com.google.android.gms.common.internal.safeparcel.SafeParcelableSerializer
import com.google.android.gms.location.*
import com.google.android.gms.location.internal.*
import com.google.android.gms.location.internal.DeviceOrientationRequestUpdateData.REMOVE_UPDATES
import com.google.android.gms.location.internal.DeviceOrientationRequestUpdateData.REQUEST_UPDATES
import kotlinx.coroutines.*
import org.microg.gms.location.hasNetworkLocationServiceBuiltIn
import org.microg.gms.location.settings.*
import org.microg.gms.utils.warnOnTransactionIssues
class LocationManagerInstance(
private val context: Context,
private val locationManager: LocationManager,
private val packageName: String,
override val lifecycle: Lifecycle
) :
AbstractLocationManagerInstance(), LifecycleOwner {
// region Geofences
override fun addGeofences(geofencingRequest: GeofencingRequest?, pendingIntent: PendingIntent?, callbacks: IGeofencerCallbacks?) {
Log.d(TAG, "Not yet implemented: addGeofences by ${getClientIdentity().packageName}")
}
override fun removeGeofences(request: RemoveGeofencingRequest?, callback: IGeofencerCallbacks?) {
Log.d(TAG, "Not yet implemented: removeGeofences by ${getClientIdentity().packageName}")
}
override fun removeAllGeofences(callbacks: IGeofencerCallbacks?, packageName: String?) {
Log.d(TAG, "Not yet implemented: removeAllGeofences by ${getClientIdentity().packageName}")
}
// endregion
// region Activity
override fun getLastActivity(packageName: String?): ActivityRecognitionResult {
Log.d(TAG, "Not yet implemented: getLastActivity by ${getClientIdentity().packageName}")
return ActivityRecognitionResult(listOf(DetectedActivity(DetectedActivity.UNKNOWN, 0)), System.currentTimeMillis(), SystemClock.elapsedRealtime())
}
override fun requestActivityTransitionUpdates(request: ActivityTransitionRequest?, pendingIntent: PendingIntent?, callback: IStatusCallback?) {
Log.d(TAG, "Not yet implemented: requestActivityTransitionUpdates by ${getClientIdentity().packageName}")
callback?.onResult(Status.SUCCESS)
}
override fun removeActivityTransitionUpdates(pendingIntent: PendingIntent?, callback: IStatusCallback?) {
Log.d(TAG, "Not yet implemented: removeActivityTransitionUpdates by ${getClientIdentity().packageName}")
callback?.onResult(Status.SUCCESS)
}
override fun requestActivityUpdatesWithCallback(request: ActivityRecognitionRequest?, pendingIntent: PendingIntent?, callback: IStatusCallback?) {
Log.d(TAG, "Not yet implemented: requestActivityUpdatesWithCallback by ${getClientIdentity().packageName}")
callback?.onResult(Status.SUCCESS)
}
override fun removeActivityUpdates(callbackIntent: PendingIntent?) {
Log.d(TAG, "Not yet implemented: removeActivityUpdates by ${getClientIdentity().packageName}")
}
// endregion
// region Sleep
override fun removeSleepSegmentUpdates(pendingIntent: PendingIntent?, callback: IStatusCallback?) {
Log.d(TAG, "Not yet implemented: removeSleepSegmentUpdates by ${getClientIdentity().packageName}")
callback?.onResult(Status.SUCCESS)
}
override fun requestSleepSegmentUpdates(pendingIntent: PendingIntent?, request: SleepSegmentRequest?, callback: IStatusCallback?) {
Log.d(TAG, "Not yet implemented: requestSleepSegmentUpdates by ${getClientIdentity().packageName}")
callback?.onResult(Status.SUCCESS)
}
// endregion
// region Location
override fun flushLocations(callback: IFusedLocationProviderCallback?) {
Log.d(TAG, "flushLocations by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
Log.d(TAG, "Not yet implemented: flushLocations")
}
override fun getLocationAvailabilityWithReceiver(request: LocationAvailabilityRequest, receiver: LocationReceiver) {
Log.d(TAG, "getLocationAvailabilityWithReceiver by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
val callback = receiver.availabilityStatusCallback
val clientIdentity = getClientIdentity()
lifecycleScope.launchWhenStarted {
try {
callback.onLocationAvailabilityStatus(Status.SUCCESS, locationManager.getLocationAvailability(clientIdentity, request))
} catch (e: Exception) {
try {
callback.onLocationAvailabilityStatus(Status(CommonStatusCodes.ERROR, e.message), LocationAvailability.UNAVAILABLE)
} catch (e2: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
}
override fun getCurrentLocationWithReceiver(request: CurrentLocationRequest, receiver: LocationReceiver): ICancelToken {
Log.d(TAG, "getCurrentLocationWithReceiver by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
var returned = false
val callback = receiver.statusCallback
val clientIdentity = getClientIdentity()
val binderIdentity = Binder()
val job = lifecycleScope.launchWhenStarted {
try {
val scope = this
val callbackForRequest = object : ILocationCallback.Stub() {
override fun onLocationResult(result: LocationResult?) {
if (!returned) runCatching { callback.onLocationStatus(Status.SUCCESS, result?.lastLocation) }
returned = true
scope.cancel()
}
override fun onLocationAvailability(availability: LocationAvailability?) {
// Ignore
}
override fun cancel() {
if (!returned) runCatching { callback.onLocationStatus(Status.SUCCESS, null) }
returned = true
scope.cancel()
}
}
val currentLocationRequest = LocationRequest.Builder(request.priority, 1000)
.setGranularity(request.granularity)
.setMaxUpdateAgeMillis(request.maxUpdateAgeMillis)
.setDurationMillis(request.durationMillis)
.setPriority(request.priority)
.setWorkSource(request.workSource)
.setThrottleBehavior(request.throttleBehavior)
.build()
locationManager.addBinderRequest(clientIdentity, binderIdentity, callbackForRequest, currentLocationRequest)
awaitCancellation()
} catch (e: CancellationException) {
// Don't send result. Either this was cancelled from the CancelToken or because a location was retrieved.
// Both cases send the result themselves.
} catch (e: Exception) {
try {
if (!returned) callback.onLocationStatus(Status(CommonStatusCodes.ERROR, e.message), null)
returned = true
} catch (e2: Exception) {
Log.w(TAG, "Failed", e)
}
} finally {
runCatching { locationManager.removeBinderRequest(binderIdentity) }
}
}
return object : ICancelToken.Stub() {
override fun cancel() {
if (!returned) runCatching { callback.onLocationStatus(Status.CANCELED, null) }
returned = true
job.cancel()
}
}
}
override fun getLastLocationWithReceiver(request: LastLocationRequest, receiver: LocationReceiver) {
Log.d(TAG, "getLastLocationWithReceiver by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
val callback = receiver.statusCallback
val clientIdentity = getClientIdentity()
lifecycleScope.launchWhenStarted {
try {
callback.onLocationStatus(Status.SUCCESS, locationManager.getLastLocation(clientIdentity, request))
} catch (e: Exception) {
try {
callback.onLocationStatus(Status(CommonStatusCodes.ERROR, e.message), null)
} catch (e2: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
}
override fun requestLocationSettingsDialog(settingsRequest: LocationSettingsRequest?, callback: ISettingsCallbacks?, packageName: String?) {
Log.d(TAG, "requestLocationSettingsDialog by ${getClientIdentity().packageName} $settingsRequest")
val clientIdentity = getClientIdentity()
lifecycleScope.launchWhenStarted {
val states = context.getDetailedLocationSettingsStates()
val requests = settingsRequest?.requests?.map {
it.priority to (if (it.granularity == Granularity.GRANULARITY_PERMISSION_LEVEL) context.granularityFromPermission(clientIdentity) else it.granularity)
}.orEmpty()
val gpsRequested = requests.any { it.first == Priority.PRIORITY_HIGH_ACCURACY && it.second == Granularity.GRANULARITY_FINE }
val networkLocationRequested = requests.any { it.first <= Priority.PRIORITY_LOW_POWER && it.second >= Granularity.GRANULARITY_COARSE }
val bleRequested = settingsRequest?.needBle == true
// Starting Android 10, fine location permission is required to scan for wifi networks
val networkLocationRequiresFine = context.hasNetworkLocationServiceBuiltIn() && SDK_INT >= 29
val statusCode = when {
// Permission checks
gpsRequested && states.gpsPresent && !states.fineLocationPermission -> CommonStatusCodes.RESOLUTION_REQUIRED
networkLocationRequested && states.networkLocationPresent && !states.coarseLocationPermission -> CommonStatusCodes.RESOLUTION_REQUIRED
networkLocationRequested && states.networkLocationPresent && networkLocationRequiresFine && !states.fineLocationPermission -> CommonStatusCodes.RESOLUTION_REQUIRED
// Enabled checks
gpsRequested && states.gpsPresent && !states.gpsUsable -> CommonStatusCodes.RESOLUTION_REQUIRED
networkLocationRequested && states.networkLocationPresent && !states.networkLocationUsable -> CommonStatusCodes.RESOLUTION_REQUIRED
bleRequested && states.blePresent && !states.bleUsable -> CommonStatusCodes.RESOLUTION_REQUIRED
// Feature not present checks
gpsRequested && !states.gpsPresent -> LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE
networkLocationRequested && !states.networkLocationPresent -> LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE
bleRequested && !states.blePresent -> LocationSettingsStatusCodes.SETTINGS_CHANGE_UNAVAILABLE
else -> CommonStatusCodes.SUCCESS
}
val resolution = if (statusCode == CommonStatusCodes.RESOLUTION_REQUIRED) {
val intent = Intent(ACTION_LOCATION_SETTINGS_CHECKER)
intent.setPackage(context.packageName)
intent.putExtra(EXTRA_ORIGINAL_PACKAGE_NAME, clientIdentity.packageName)
intent.putExtra(EXTRA_SETTINGS_REQUEST, SafeParcelableSerializer.serializeToBytes(settingsRequest))
PendingIntentCompat.getActivity(context, clientIdentity.packageName.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT, true)
} else null
val status = Status(statusCode, LocationSettingsStatusCodes.getStatusCodeString(statusCode), resolution)
Log.d(TAG, "requestLocationSettingsDialog by ${getClientIdentity().packageName} returns $status")
runCatching { callback?.onLocationSettingsResult(LocationSettingsResult(status, states.toApi())) }
}
}
override fun isGoogleLocationAccuracyEnabled(callback: IBooleanStatusCallback?) {
Log.d(TAG, "isGoogleLocationAccuracyEnabled by ${getClientIdentity().packageName}")
callback?.onBooleanStatus(Status.SUCCESS, true)
}
override fun setGoogleLocationAccuracy(request: SetGoogleLocationAccuracyRequest?, callback: IStatusCallback?) {
Log.d(TAG, "setGoogleLocationAccuracy by ${getClientIdentity().packageName}")
callback?.onResult(Status.SUCCESS)
}
// region Mock locations
override fun setMockModeWithCallback(mockMode: Boolean, callback: IStatusCallback) {
Log.d(TAG, "setMockModeWithCallback by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
val clientIdentity = getClientIdentity()
lifecycleScope.launchWhenStarted {
try {
Log.d(TAG, "Not yet implemented: setMockModeWithCallback")
callback.onResult(Status.SUCCESS)
} catch (e: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
override fun setMockLocationWithCallback(mockLocation: Location, callback: IStatusCallback) {
Log.d(TAG, "setMockLocationWithCallback by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
val clientIdentity = getClientIdentity()
lifecycleScope.launchWhenStarted {
try {
Log.d(TAG, "Not yet implemented: setMockLocationWithCallback")
callback.onResult(Status.SUCCESS)
} catch (e: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
// endregion
// region Location updates
override fun registerLocationUpdates(
oldBinder: IBinder?,
binder: IBinder,
callback: ILocationCallback,
request: LocationRequest,
statusCallback: IStatusCallback
) {
Log.d(TAG, "registerLocationUpdates (callback) by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
val clientIdentity = getClientIdentity()
lifecycleScope.launchWhenStarted {
try {
locationManager.updateBinderRequest(clientIdentity, oldBinder, binder, callback, request)
statusCallback.onResult(Status.SUCCESS)
} catch (e: Exception) {
try {
statusCallback.onResult(Status(CommonStatusCodes.ERROR, e.message))
} catch (e2: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
}
override fun registerLocationUpdates(pendingIntent: PendingIntent, request: LocationRequest, statusCallback: IStatusCallback) {
Log.d(TAG, "registerLocationUpdates (intent) by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
val clientIdentity = getClientIdentity()
lifecycleScope.launchWhenStarted {
try {
locationManager.addIntentRequest(clientIdentity, pendingIntent, request)
statusCallback.onResult(Status.SUCCESS)
} catch (e: Exception) {
try {
statusCallback.onResult(Status(CommonStatusCodes.ERROR, e.message))
} catch (e2: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
}
override fun unregisterLocationUpdates(binder: IBinder, statusCallback: IStatusCallback) {
Log.d(TAG, "unregisterLocationUpdates (callback) by ${getClientIdentity().packageName}")
lifecycleScope.launchWhenStarted {
try {
locationManager.removeBinderRequest(binder)
statusCallback.onResult(Status.SUCCESS)
} catch (e: Exception) {
try {
statusCallback.onResult(Status(CommonStatusCodes.ERROR, e.message))
} catch (e2: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
}
override fun unregisterLocationUpdates(pendingIntent: PendingIntent, statusCallback: IStatusCallback) {
Log.d(TAG, "unregisterLocationUpdates (intent) by ${getClientIdentity().packageName}")
lifecycleScope.launchWhenStarted {
try {
locationManager.removeIntentRequest(pendingIntent)
statusCallback.onResult(Status.SUCCESS)
} catch (e: Exception) {
try {
statusCallback.onResult(Status(CommonStatusCodes.ERROR, e.message))
} catch (e2: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
}
// endregion
// endregion
// region Device Orientation
override fun updateDeviceOrientationRequest(request: DeviceOrientationRequestUpdateData) {
Log.d(TAG, "updateDeviceOrientationRequest by ${getClientIdentity().packageName}")
checkHasAnyLocationPermission()
val clientIdentity = getClientIdentity()
val callback = request.fusedLocationProviderCallback
lifecycleScope.launchWhenStarted {
try {
when (request.opCode) {
REQUEST_UPDATES -> locationManager.deviceOrientationManager.add(clientIdentity, request.request, request.listener)
REMOVE_UPDATES -> locationManager.deviceOrientationManager.remove(clientIdentity, request.listener)
else -> throw UnsupportedOperationException("Op code ${request.opCode} not supported")
}
callback?.onFusedLocationProviderResult(FusedLocationProviderResult.SUCCESS)
} catch (e: Exception) {
try {
callback?.onFusedLocationProviderResult(FusedLocationProviderResult.create(Status(CommonStatusCodes.ERROR, e.message)))
} catch (e2: Exception) {
Log.w(TAG, "Failed", e)
}
}
}
}
// endregion
private fun getClientIdentity() = ClientIdentity(packageName).apply { uid = getCallingUid(); pid = getCallingPid() }
private fun checkHasAnyLocationPermission() = checkHasAnyPermission(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION)
private fun checkHasAnyPermission(vararg permissions: String) {
for (permission in permissions) {
if (context.packageManager.checkPermission(permission, packageName) == PERMISSION_GRANTED) {
return
}
}
throw SecurityException("$packageName does not have any of $permissions")
}
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean =
warnOnTransactionIssues(code, reply, flags, TAG) { super.onTransact(code, data, reply, flags) }
}

View file

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.content.Intent
import android.location.Location
import android.os.Binder
import android.os.Process
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.internal.ConnectionInfo
import com.google.android.gms.common.internal.GetServiceRequest
import com.google.android.gms.common.internal.IGmsCallbacks
import org.microg.gms.BaseService
import org.microg.gms.common.GmsService
import org.microg.gms.common.PackageUtils
import org.microg.gms.location.EXTRA_LOCATION
import org.microg.gms.utils.IntentCacheManager
import java.io.FileDescriptor
import java.io.PrintWriter
class LocationManagerService : BaseService(TAG, GmsService.LOCATION_MANAGER) {
private val locationManager = LocationManager(this, lifecycle)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
locationManager.start()
if (Binder.getCallingUid() == Process.myUid() && intent?.action == ACTION_REPORT_LOCATION) {
val location = intent.getParcelableExtra<Location>(EXTRA_LOCATION)
if (location != null) {
locationManager.updateNetworkLocation(location)
}
}
if (intent != null && IntentCacheManager.isCache(intent)) {
locationManager.handleCacheIntent(intent)
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
locationManager.stop()
super.onDestroy()
}
override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService?) {
val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName)
?: throw IllegalArgumentException("Missing package name")
locationManager.start()
callback.onPostInitCompleteWithConnectionInfo(
CommonStatusCodes.SUCCESS,
LocationManagerInstance(this, locationManager, packageName, lifecycle).asBinder(),
ConnectionInfo().apply { features = FEATURES }
)
}
override fun dump(fd: FileDescriptor?, writer: PrintWriter, args: Array<out String>?) {
super.dump(fd, writer, args)
locationManager.dump(writer)
}
companion object {
const val ACTION_REPORT_LOCATION = "org.microg.gms.location.manager.ACTION_REPORT_LOCATION"
}
}

View file

@ -0,0 +1,147 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.location.Location
import android.os.SystemClock
import androidx.core.location.LocationCompat
import com.google.android.gms.location.Granularity
import com.google.android.gms.location.Granularity.GRANULARITY_COARSE
import java.security.SecureRandom
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.round
class LocationPostProcessor {
private var nextUpdateElapsedRealtime = 0L
private val random = SecureRandom()
private var latitudeOffsetMeters = nextOffsetMeters()
private var longitudeOffsetMeters = nextOffsetMeters()
// We cache the latest coarsed location
private var coarseLocationBefore: Location? = null
private var coarseLocationAfter: Location? = null
private fun nextOffsetMeters(): Double = random.nextGaussian() * (COARSE_ACCURACY_METERS / 4.0)
// We change the offset regularly to ensure there is no possibility to determine the offset and thus know exact locations when at a cliff.
private fun updateOffsetMetersIfNeeded() {
if (nextUpdateElapsedRealtime >= SystemClock.elapsedRealtime()) {
latitudeOffsetMeters = latitudeOffsetMeters * 0.97 + nextOffsetMeters() * 0.03
longitudeOffsetMeters = longitudeOffsetMeters * 0.97 + nextOffsetMeters() * 0.03
nextUpdateElapsedRealtime = SystemClock.elapsedRealtime() + COARSE_UPDATE_TIME
}
}
fun process(location: Location?, granularity: @Granularity Int, forGoogle: Boolean): Location? {
if (location == null) return null
val extrasAllowList = if (forGoogle) GOOGLE_EXTRAS_LIST else PUBLIC_EXTRAS_LIST
return when {
granularity == GRANULARITY_COARSE -> {
if (location == coarseLocationBefore || location == coarseLocationAfter) {
coarseLocationAfter
} else {
val newLocation = Location(location)
newLocation.removeBearing()
newLocation.removeSpeed()
newLocation.removeAltitude()
if (LocationCompat.hasBearingAccuracy(newLocation)) LocationCompat.setBearingAccuracyDegrees(newLocation, 0f)
if (LocationCompat.hasSpeedAccuracy(newLocation)) LocationCompat.setSpeedAccuracyMetersPerSecond(newLocation, 0f)
if (LocationCompat.hasVerticalAccuracy(newLocation)) LocationCompat.setVerticalAccuracyMeters(newLocation, 0f)
newLocation.extras = null
newLocation.accuracy = max(newLocation.accuracy, COARSE_ACCURACY_METERS)
updateOffsetMetersIfNeeded()
val latitudeAccuracy = metersToLongitudeAtEquator(COARSE_ACCURACY_METERS.toDouble())
val longitudeAccuracy = metersToLongitudeAtLatitude(COARSE_ACCURACY_METERS.toDouble(), location.latitude)
val offsetLatitude = coerceLatitude(location.latitude) + metersToLongitudeAtEquator(latitudeOffsetMeters)
newLocation.latitude = coerceLatitude(round(offsetLatitude / latitudeAccuracy) * latitudeAccuracy)
val offsetLongitude = coerceLongitude(location.longitude) + metersToLongitudeAtLatitude(longitudeOffsetMeters, newLocation.latitude)
newLocation.longitude = coerceLongitude(round(offsetLongitude / longitudeAccuracy) * longitudeAccuracy)
coarseLocationBefore = location
coarseLocationAfter = newLocation
newLocation
}
}
location.hasAnyNonAllowedExtra(extrasAllowList) -> Location(location).stripExtras(extrasAllowList)
else -> location
}
}
companion object {
private const val COARSE_ACCURACY_METERS = 2000f
private const val COARSE_UPDATE_TIME = 3600_000
private const val EQUATOR_METERS_PER_LONGITUDE = 111000.0
val PUBLIC_EXTRAS_LIST = listOf(
"noGPSLocation",
LocationCompat.EXTRA_VERTICAL_ACCURACY,
LocationCompat.EXTRA_BEARING_ACCURACY,
LocationCompat.EXTRA_SPEED_ACCURACY,
LocationCompat.EXTRA_MSL_ALTITUDE,
LocationCompat.EXTRA_MSL_ALTITUDE_ACCURACY,
LocationCompat.EXTRA_IS_MOCK,
)
val GOOGLE_EXTRAS_LIST = listOf(
"noGPSLocation",
LocationCompat.EXTRA_VERTICAL_ACCURACY,
LocationCompat.EXTRA_BEARING_ACCURACY,
LocationCompat.EXTRA_SPEED_ACCURACY,
LocationCompat.EXTRA_MSL_ALTITUDE,
LocationCompat.EXTRA_MSL_ALTITUDE_ACCURACY,
LocationCompat.EXTRA_IS_MOCK,
"locationType",
"levelId",
"levelNumberE3",
"floorLabel",
"indoorProbability",
"wifiScan"
)
private fun Location.hasAnyNonAllowedExtra(allowList: List<String>): Boolean {
for (key in extras?.keySet().orEmpty()) {
if (key !in allowList) {
return true
}
}
return false
}
private fun Location.stripExtras(allowList: List<String>): Location {
val extras = extras
for (key in extras?.keySet().orEmpty()) {
if (key !in allowList) {
extras?.remove(key)
}
}
this.extras = if (extras?.isEmpty == true) null else extras
return this
}
private fun metersToLongitudeAtEquator(meters: Double): Double = meters / EQUATOR_METERS_PER_LONGITUDE
private fun metersToLongitudeAtLatitude(meters: Double, latitude: Double): Double = metersToLongitudeAtEquator(meters) / cos(Math.toRadians(latitude))
/**
* Coerce latitude value to be between -89.99999° and 89.99999°.
*
* Sorry to those, who actually are at the geographical north/south pole, but at exactly 90°, our math wouldn't work out anymore.
*/
private fun coerceLatitude(latitude: Double): Double = latitude.coerceIn(-89.99999, 89.99999)
/**
* Coerce longitude value to be between -180.00° and 180.00°.
*/
private fun coerceLongitude(longitude: Double): Double = (longitude % 360.0).let {
when {
it >= 180.0 -> it - 360.0
it < -180.0 -> it + 360.0
else -> it
}
}
}
}

View file

@ -0,0 +1,491 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.Manifest
import android.app.AppOpsManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
import android.os.*
import android.os.Build.VERSION.SDK_INT
import android.util.Log
import androidx.annotation.GuardedBy
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.google.android.gms.location.*
import com.google.android.gms.location.Granularity.*
import com.google.android.gms.location.Priority.*
import com.google.android.gms.location.internal.ClientIdentity
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.microg.gms.location.GranularityUtil
import org.microg.gms.location.PriorityUtil
import org.microg.gms.location.elapsedMillis
import org.microg.gms.location.formatDuration
import org.microg.gms.utils.IntentCacheManager
import org.microg.gms.utils.WorkSourceUtil
import java.io.PrintWriter
import kotlin.math.max
class LocationRequestManager(private val context: Context, override val lifecycle: Lifecycle, private val postProcessor: LocationPostProcessor, private val database: LocationAppsDatabase = LocationAppsDatabase(context), private val requestDetailsUpdatedCallback: () -> Unit) :
IBinder.DeathRecipient, LifecycleOwner {
private val lock = Mutex()
private val binderRequests = mutableMapOf<IBinder, LocationRequestHolder>()
private val pendingIntentRequests = mutableMapOf<PendingIntent, LocationRequestHolder>()
private val cacheManager by lazy { IntentCacheManager.create<LocationManagerService, LocationRequestHolderParcelable>(context, CACHE_TYPE) }
var priority: @Priority Int = PRIORITY_PASSIVE
var granularity: @Granularity Int = GRANULARITY_PERMISSION_LEVEL
private set
var intervalMillis: Long = Long.MAX_VALUE
private set
var workSource = WorkSource()
private set
private var grantedPermissions: List<Int> = locationPermissions.map { ContextCompat.checkSelfPermission(context, it) }
private var permissionChanged: Boolean = false
private var requestDetailsUpdated = false
private var checkingWhileHighAccuracy = false
private val appOpsLock = Any()
@GuardedBy("appOpsLock")
private var currentAppOps = emptyMap<ClientIdentity, Boolean>()
override fun binderDied() {
lifecycleScope.launchWhenStarted {
lock.withLock {
val toRemove = binderRequests.keys.filter { !it.isBinderAlive }.toList()
for (binder in toRemove) {
binderRequests.remove(binder)
}
recalculateRequests()
}
notifyRequestDetailsUpdated()
}
}
suspend fun add(binder: IBinder, clientIdentity: ClientIdentity, callback: ILocationCallback, request: LocationRequest, lastLocationCapsule: LastLocationCapsule) {
update(null, binder, clientIdentity, callback, request, lastLocationCapsule)
}
suspend fun update(oldBinder: IBinder?, binder: IBinder, clientIdentity: ClientIdentity, callback: ILocationCallback, request: LocationRequest, lastLocationCapsule: LastLocationCapsule): Boolean {
var new = false
lock.withLock {
try {
oldBinder?.unlinkToDeath(this, 0)
} catch (e: Exception) {
Log.w(TAG, "update: ", e)
}
val holder = binderRequests.remove(oldBinder)
new = holder == null
try {
val startedHolder = holder?.update(callback, request) ?: LocationRequestHolder(context, clientIdentity, request, callback, null).start().also {
var effectiveGranularity = it.effectiveGranularity
if (effectiveGranularity == GRANULARITY_FINE && database.getForceCoarse(it.clientIdentity.packageName) && !clientIdentity.isSelfUser()) effectiveGranularity = GRANULARITY_COARSE
val lastLocation = lastLocationCapsule.getLocation(effectiveGranularity, request.maxUpdateAgeMillis)
if (lastLocation != null) it.processNewLocation(lastLocation)
}
binderRequests[binder] = startedHolder
binder.linkToDeath(this, 0)
} catch (e: Exception) {
holder?.cancel()
}
recalculateRequests()
}
notifyRequestDetailsUpdated()
return new
}
suspend fun remove(oldBinder: IBinder) {
lock.withLock {
oldBinder.unlinkToDeath(this, 0)
val holder = binderRequests.remove(oldBinder)
if (holder != null) {
holder.cancel()
recalculateRequests()
}
}
notifyRequestDetailsUpdated()
}
suspend fun add(pendingIntent: PendingIntent, clientIdentity: ClientIdentity, request: LocationRequest, lastLocationCapsule: LastLocationCapsule) {
lock.withLock {
try {
pendingIntentRequests[pendingIntent] = LocationRequestHolder(context, clientIdentity, request, null, pendingIntent).start().also {
cacheManager.add(it.asParcelable()) { it.pendingIntent == pendingIntent }
var effectiveGranularity = it.effectiveGranularity
if (effectiveGranularity == GRANULARITY_FINE && database.getForceCoarse(it.clientIdentity.packageName) && !clientIdentity.isSelfUser()) effectiveGranularity = GRANULARITY_COARSE
val lastLocation = lastLocationCapsule.getLocation(effectiveGranularity, request.maxUpdateAgeMillis)
if (lastLocation != null) it.processNewLocation(lastLocation)
}
} catch (e: Exception) {
// Ignore
}
recalculateRequests()
}
notifyRequestDetailsUpdated()
}
suspend fun remove(pendingIntent: PendingIntent) {
lock.withLock {
cacheManager.removeIf { it.pendingIntent == pendingIntent }
if (pendingIntentRequests.remove(pendingIntent) != null) recalculateRequests()
}
notifyRequestDetailsUpdated()
}
private fun <T> processNewLocation(lastLocationCapsule: LastLocationCapsule, map: Map<T, LocationRequestHolder>): Pair<Set<T>, Set<T>> {
val toRemove = mutableSetOf<T>()
val updated = mutableSetOf<T>()
for ((key, holder) in map) {
try {
var effectiveGranularity = holder.effectiveGranularity
if (effectiveGranularity == GRANULARITY_FINE && database.getForceCoarse(holder.clientIdentity.packageName) && !holder.clientIdentity.isSelfUser()) effectiveGranularity = GRANULARITY_COARSE
val location = lastLocationCapsule.getLocation(effectiveGranularity)
postProcessor.process(location, effectiveGranularity, holder.clientIdentity.isGoogle(context))?.let {
if (holder.processNewLocation(it)) {
if (!holder.clientIdentity.isSelfUser()) database.noteAppLocation(holder.clientIdentity.packageName, it)
updated.add(key)
}
}
} catch (e: Exception) {
Log.w(TAG, "Exception while processing for ${holder.workSource}: ${e.message}")
toRemove.add(key)
}
}
return toRemove to updated
}
suspend fun processNewLocation(lastLocationCapsule: LastLocationCapsule) {
lock.withLock {
val (pendingIntentsToRemove, pendingIntentsUpdated) = processNewLocation(lastLocationCapsule, pendingIntentRequests)
for (pendingIntent in pendingIntentsToRemove) {
cacheManager.removeIf { it.pendingIntent == pendingIntent }
pendingIntentRequests.remove(pendingIntent)
}
for (pendingIntent in pendingIntentsUpdated) {
cacheManager.add(pendingIntentRequests[pendingIntent]!!.asParcelable()) { it.pendingIntent == pendingIntent }
}
val (bindersToRemove, _) = processNewLocation(lastLocationCapsule, binderRequests)
for (binder in bindersToRemove) {
try {
binderRequests[binder]?.cancel()
} catch (e: Exception) {
// Ignore
}
binderRequests.remove(binder)
}
if (pendingIntentsToRemove.isNotEmpty() || bindersToRemove.isNotEmpty()) {
recalculateRequests()
}
}
notifyRequestDetailsUpdated()
}
private fun recalculateRequests() {
val merged = binderRequests.values + pendingIntentRequests.values
val newGranularity = merged.maxOfOrNull { it.effectiveGranularity } ?: GRANULARITY_PERMISSION_LEVEL
val newPriority = merged.minOfOrNull { it.effectivePriority } ?: PRIORITY_PASSIVE
val newIntervalMillis = merged.minOfOrNull { it.intervalMillis } ?: Long.MAX_VALUE
val newWorkSource = WorkSource()
for (holder in merged) {
newWorkSource.add(holder.workSource)
}
if (newPriority == PRIORITY_HIGH_ACCURACY && priority != PRIORITY_HIGH_ACCURACY) lifecycleScope.launchWhenStarted { checkWhileHighAccuracy() }
if (newPriority != priority || newGranularity != granularity || newIntervalMillis != intervalMillis || newWorkSource != workSource || permissionChanged) {
priority = newPriority
granularity = newGranularity
intervalMillis = newIntervalMillis
workSource = newWorkSource
requestDetailsUpdated = true
permissionChanged = false
}
}
private fun updateAppOps() {
synchronized(appOpsLock) {
val newAppOps = mutableMapOf<ClientIdentity, Boolean>()
val merged = binderRequests.values + pendingIntentRequests.values
for (request in merged) {
if (request.effectivePriority >= PRIORITY_PASSIVE || request.clientIdentity.isSelfUser()) continue
if (!newAppOps.containsKey(request.clientIdentity)) {
newAppOps[request.clientIdentity] = request.effectiveHighPower
} else if (request.effectiveHighPower) {
newAppOps[request.clientIdentity] = true
}
}
Log.d(TAG, "Updating app ops for location requests, change attribution to: ${newAppOps.keys.map { it.packageName }.joinToString().takeIf { it.isNotEmpty() } ?: "none"}")
for (oldAppOp in currentAppOps) {
context.finishAppOp(AppOpsManager.OPSTR_MONITOR_LOCATION, oldAppOp.key)
if (oldAppOp.value) {
context.finishAppOp(AppOpsManager.OPSTR_MONITOR_HIGH_POWER_LOCATION, oldAppOp.key)
}
}
for (newAppOp in newAppOps) {
context.startAppOp(AppOpsManager.OPSTR_MONITOR_LOCATION, newAppOp.key)
if (newAppOp.value) {
context.startAppOp(AppOpsManager.OPSTR_MONITOR_HIGH_POWER_LOCATION, newAppOp.key)
}
}
currentAppOps = newAppOps
}
}
private suspend fun check() {
lock.withLock {
val pendingIntentsToRemove = mutableSetOf<PendingIntent>()
for ((key, holder) in pendingIntentRequests) {
try {
holder.check()
} catch (e: Exception) {
Log.w(TAG, "Exception while checking for ${holder.workSource}", e)
pendingIntentsToRemove.add(key)
}
}
for (pendingIntent in pendingIntentsToRemove) {
cacheManager.removeIf { it.pendingIntent == pendingIntent }
pendingIntentRequests.remove(pendingIntent)
}
val bindersToRemove = mutableSetOf<IBinder>()
for ((key, holder) in binderRequests) {
try {
holder.check()
} catch (e: Exception) {
Log.w(TAG, "Exception while checking for ${holder.workSource}", e)
bindersToRemove.add(key)
}
}
for (binder in bindersToRemove) {
try {
binderRequests[binder]?.cancel()
} catch (e: Exception) {
// Ignore
}
binderRequests.remove(binder)
}
if (grantedPermissions.any { it != PackageManager.PERMISSION_GRANTED }) {
val grantedPermissions = locationPermissions.map { ContextCompat.checkSelfPermission(context, it) }
if (grantedPermissions == this.grantedPermissions) {
this.grantedPermissions = grantedPermissions
permissionChanged = true
}
}
if (pendingIntentsToRemove.isNotEmpty() || bindersToRemove.isNotEmpty() || permissionChanged) {
recalculateRequests()
}
}
notifyRequestDetailsUpdated()
}
private suspend fun checkWhileHighAccuracy() {
if (checkingWhileHighAccuracy) return
checkingWhileHighAccuracy = true
while (priority == PRIORITY_HIGH_ACCURACY) {
check()
delay(1000)
}
checkingWhileHighAccuracy = false
}
private fun notifyRequestDetailsUpdated() {
if (!requestDetailsUpdated) return
requestDetailsUpdatedCallback()
updateAppOps()
requestDetailsUpdated = false
}
fun stop() {
binderRequests.clear()
pendingIntentRequests.clear()
recalculateRequests()
}
fun start() {
recalculateRequests()
notifyRequestDetailsUpdated()
}
fun dump(writer: PrintWriter) {
writer.println("Request cache: id=${cacheManager.getId()} size=${cacheManager.getEntries().size}")
writer.println("Current location request (${GranularityUtil.granularityToString(granularity)}, ${PriorityUtil.priorityToString(priority)}, ${intervalMillis.formatDuration()} from ${workSource})")
for (request in binderRequests.values.toList()) {
writer.println("- bound ${request.workSource} ${request.intervalMillis.formatDuration()} ${GranularityUtil.granularityToString(request.effectiveGranularity)}, ${PriorityUtil.priorityToString(request.effectivePriority)} (pending: ${request.updatesPending.let { if (it == Int.MAX_VALUE) "\u221e" else "$it" }} ${request.timePendingMillis.formatDuration()}) app-op: ${when(currentAppOps[request.clientIdentity]) { null -> "false"; false -> "low"; true -> "high"}}")
}
for (request in pendingIntentRequests.values.toList()) {
writer.println("- pending intent ${request.workSource} ${request.intervalMillis.formatDuration()} ${GranularityUtil.granularityToString(request.effectiveGranularity)}, ${PriorityUtil.priorityToString(request.effectivePriority)} (pending: ${request.updatesPending.let { if (it == Int.MAX_VALUE) "\u221e" else "$it" }} ${request.timePendingMillis.formatDuration()}) app-op: ${when(currentAppOps[request.clientIdentity]) { null -> "false"; false -> "low"; true -> "high"}}")
}
}
fun handleCacheIntent(intent: Intent) {
cacheManager.processIntent(intent)
for (parcelable in cacheManager.getEntries()) {
pendingIntentRequests[parcelable.pendingIntent] = LocationRequestHolder(context, parcelable)
}
recalculateRequests()
notifyRequestDetailsUpdated()
}
companion object {
private val locationPermissions = listOfNotNull(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
if (SDK_INT >= 29) Manifest.permission.ACCESS_BACKGROUND_LOCATION else null
)
const val CACHE_TYPE = 1
private class LocationRequestHolderParcelable(
val clientIdentity: ClientIdentity,
val request: LocationRequest,
val pendingIntent: PendingIntent,
val start: Long,
val updates: Int,
val lastLocation: Location?
) : Parcelable {
constructor(parcel: Parcel) : this(
parcel.readParcelable(ClientIdentity::class.java.classLoader)!!,
parcel.readParcelable(LocationRequest::class.java.classLoader)!!,
parcel.readParcelable(PendingIntent::class.java.classLoader)!!,
parcel.readLong(),
parcel.readInt(),
parcel.readParcelable(Location::class.java.classLoader)
)
override fun writeToParcel(parcel: Parcel, flags: Int) {
parcel.writeParcelable(clientIdentity, flags)
parcel.writeParcelable(request, flags)
parcel.writeParcelable(pendingIntent, flags)
parcel.writeLong(start)
parcel.writeInt(updates)
parcel.writeParcelable(lastLocation, flags)
}
override fun describeContents(): Int {
return 0
}
companion object CREATOR : Parcelable.Creator<LocationRequestHolderParcelable> {
override fun createFromParcel(parcel: Parcel): LocationRequestHolderParcelable {
return LocationRequestHolderParcelable(parcel)
}
override fun newArray(size: Int): Array<LocationRequestHolderParcelable?> {
return arrayOfNulls(size)
}
}
}
private class LocationRequestHolder(
private val context: Context,
val clientIdentity: ClientIdentity,
private var request: LocationRequest,
private var callback: ILocationCallback?,
private val pendingIntent: PendingIntent?
) {
private var start = SystemClock.elapsedRealtime()
private var updates = 0
private var lastLocation: Location? = null
constructor(context: Context, parcelable: LocationRequestHolderParcelable) : this(context, parcelable.clientIdentity, parcelable.request, null, parcelable.pendingIntent) {
start = parcelable.start
updates = parcelable.updates
lastLocation = parcelable.lastLocation
}
fun asParcelable() = LocationRequestHolderParcelable(clientIdentity, request, pendingIntent!!, start, updates, lastLocation)
val permissionGranularity: @Granularity Int
get() = context.granularityFromPermission(clientIdentity)
val effectiveGranularity: @Granularity Int
get() = getEffectiveGranularity(request.granularity, permissionGranularity)
val effectivePriority: @Priority Int
get() {
if (request.priority == PRIORITY_HIGH_ACCURACY && permissionGranularity < GRANULARITY_FINE) {
return PRIORITY_BALANCED_POWER_ACCURACY
}
return request.priority
}
val maxUpdateDelayMillis: Long
get() = max(max(request.maxUpdateDelayMillis, intervalMillis), 0L)
val intervalMillis: Long
get() = request.intervalMillis
val updatesPending: Int
get() = request.maxUpdates - updates
val timePendingMillis: Long
get() = request.durationMillis - (SystemClock.elapsedRealtime() - start)
var workSource: WorkSource = WorkSource(request.workSource).also { if (!clientIdentity.isSelfUser()) WorkSourceUtil.add(it, clientIdentity.uid, clientIdentity.packageName) }
private set
val effectiveHighPower: Boolean
get() = request.intervalMillis < 60000 || effectivePriority == PRIORITY_HIGH_ACCURACY
fun update(callback: ILocationCallback, request: LocationRequest): LocationRequestHolder {
val changedGranularity = request.granularity != this.request.granularity || request.granularity == GRANULARITY_PERMISSION_LEVEL
this.callback = callback
this.request = request
this.start = SystemClock.elapsedRealtime()
this.updates = 0
this.workSource = WorkSource(request.workSource).also { if (!clientIdentity.isSelfUser()) WorkSourceUtil.add(it, clientIdentity.uid, clientIdentity.packageName) }
if (changedGranularity) {
if (!context.checkAppOpForEffectiveGranularity(clientIdentity, effectiveGranularity)) throw RuntimeException("Lack of permission")
}
return this
}
fun start(): LocationRequestHolder {
if (!context.checkAppOpForEffectiveGranularity(clientIdentity, effectiveGranularity)) throw RuntimeException("Lack of permission")
return this
}
fun cancel() {
try {
callback?.cancel()
} catch (e: Exception) {
Log.w(TAG, e)
}
}
fun check() {
if (!context.checkAppOpForEffectiveGranularity(clientIdentity, effectiveGranularity)) throw RuntimeException("Lack of permission")
if (effectiveGranularity > permissionGranularity) throw RuntimeException("Lack of permission")
if (timePendingMillis < 0) throw RuntimeException("duration limit reached (active for ${(SystemClock.elapsedRealtime() - start).formatDuration()}, duration ${request.durationMillis.formatDuration()})")
if (updatesPending <= 0) throw RuntimeException("max updates reached")
if (callback?.asBinder()?.isBinderAlive == false) throw RuntimeException("Binder died")
}
fun processNewLocation(location: Location): Boolean {
check()
if (lastLocation != null && location.elapsedMillis - lastLocation!!.elapsedMillis < request.minUpdateIntervalMillis) return false
if (lastLocation != null && location.distanceTo(lastLocation!!) < request.minUpdateDistanceMeters) return false
if (lastLocation == location) return false
val returnedLocation = if (effectiveGranularity > permissionGranularity) {
throw RuntimeException("Lack of permission")
} else {
if (!context.noteAppOpForEffectiveGranularity(clientIdentity, effectiveGranularity)) {
throw RuntimeException("app op denied")
} else if (clientIdentity.isSelfProcess()) {
Location(location)
} else {
Location(location).apply { provider = "fused" }
}
}
val result = LocationResult.create(listOf(returnedLocation))
callback?.onLocationResult(result)
pendingIntent?.send(context, 0, Intent().apply { putExtra(LocationResult.EXTRA_LOCATION_RESULT, result) })
if (request.maxUpdates != Int.MAX_VALUE) updates++
check()
return true
}
init {
require(callback != null || pendingIntent != null)
}
}
}
}

View file

@ -0,0 +1,234 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.manager
import android.Manifest
import android.app.AppOpsManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Binder
import android.os.Build.VERSION.SDK_INT
import android.os.Process
import android.util.Log
import androidx.core.app.AppOpsManagerCompat
import androidx.core.content.getSystemService
import com.google.android.gms.common.Feature
import com.google.android.gms.location.*
import com.google.android.gms.location.internal.ClientIdentity
import com.google.android.gms.location.internal.IFusedLocationProviderCallback
import org.microg.gms.common.PackageUtils
import org.microg.gms.location.GranularityUtil
const val TAG = "LocationManager"
internal val FEATURES = arrayOf(
Feature("name_ulr_private", 1),
Feature("driving_mode", 6),
Feature("name_sleep_segment_request", 1),
Feature("support_context_feature_id", 1),
Feature("get_current_location", 2),
Feature("get_last_activity_feature_id", 1),
Feature("get_last_location_with_request", 1),
Feature("set_mock_mode_with_callback", 1),
Feature("set_mock_location_with_callback", 1),
Feature("inject_location_with_callback", 1),
Feature("location_updates_with_callback", 1),
Feature("user_service_developer_features", 1),
Feature("user_service_location_accuracy", 1),
Feature("user_service_safety_and_emergency", 1),
Feature("google_location_accuracy_enabled", 1),
Feature("geofences_with_callback", 1),
Feature("use_safe_parcelable_in_intents", 1)
)
fun ILocationListener.asCallback(): ILocationCallback {
return object : ILocationCallback.Stub() {
override fun onLocationResult(result: LocationResult) {
for (location in result.locations) {
onLocationChanged(location)
}
}
override fun onLocationAvailability(availability: LocationAvailability) = Unit
override fun cancel() = this@asCallback.cancel()
}
}
fun ILocationCallback.redirectCancel(fusedCallback: IFusedLocationProviderCallback?): ILocationCallback {
if (fusedCallback == null) return this
return object : ILocationCallback.Stub() {
override fun onLocationResult(result: LocationResult) = this@redirectCancel.onLocationResult(result)
override fun onLocationAvailability(availability: LocationAvailability) = this@redirectCancel.onLocationAvailability(availability)
override fun cancel() = fusedCallback.cancel()
}
}
fun ClientIdentity.isGoogle(context: Context) = PackageUtils.isGooglePackage(context, packageName)
fun ClientIdentity.isSelfProcess() = pid == Process.myPid()
fun ClientIdentity.isSelfUser() = uid == Process.myUid()
fun Context.granularityFromPermission(clientIdentity: ClientIdentity): @Granularity Int = when (PackageManager.PERMISSION_GRANTED) {
packageManager.checkPermission(Manifest.permission.ACCESS_FINE_LOCATION, clientIdentity.packageName) -> Granularity.GRANULARITY_FINE
packageManager.checkPermission(Manifest.permission.ACCESS_COARSE_LOCATION, clientIdentity.packageName) -> Granularity.GRANULARITY_COARSE
else -> Granularity.GRANULARITY_PERMISSION_LEVEL
}
fun LocationRequest.verify(context: Context, clientIdentity: ClientIdentity) {
GranularityUtil.checkValidGranularity(granularity)
if (isBypass && !clientIdentity.isSelfUser()) {
val permission = if (SDK_INT >= 33) "android.permission.LOCATION_BYPASS" else Manifest.permission.WRITE_SECURE_SETTINGS
if (context.checkPermission(permission, clientIdentity.pid, clientIdentity.uid) != PackageManager.PERMISSION_GRANTED) {
throw SecurityException("Caller must hold $permission for location bypass")
}
}
if (impersonation != null && !clientIdentity.isSelfUser()) {
Log.w(TAG, "${clientIdentity.packageName} wants to impersonate ${impersonation!!.packageName}. Ignoring.")
}
}
fun checkAppOpFromEffectiveGranularity(effectiveGranularity: @Granularity Int) = when (effectiveGranularity) {
Granularity.GRANULARITY_FINE -> AppOpsManager.OPSTR_FINE_LOCATION
Granularity.GRANULARITY_COARSE -> AppOpsManager.OPSTR_COARSE_LOCATION
else -> throw IllegalArgumentException()
}
fun persistAppOpsFromEffectiveGranularity(effectiveGranularity: @Granularity Int) = when (effectiveGranularity) {
Granularity.GRANULARITY_FINE -> listOf(AppOpsManager.OPSTR_MONITOR_LOCATION, AppOpsManager.OPSTR_MONITOR_HIGH_POWER_LOCATION)
Granularity.GRANULARITY_COARSE -> listOf(AppOpsManager.OPSTR_MONITOR_LOCATION)
else -> throw IllegalArgumentException()
}
fun getEffectiveGranularity(requestGranularity: @Granularity Int, permissionGranularity: @Granularity Int) = when {
requestGranularity == Granularity.GRANULARITY_PERMISSION_LEVEL && permissionGranularity == Granularity.GRANULARITY_PERMISSION_LEVEL -> Granularity.GRANULARITY_FINE
requestGranularity == Granularity.GRANULARITY_PERMISSION_LEVEL -> permissionGranularity
else -> requestGranularity
}
fun Context.noteAppOpForEffectiveGranularity(
clientIdentity: ClientIdentity,
effectiveGranularity: @Granularity Int,
message: String? = null
): Boolean {
return try {
val op = checkAppOpFromEffectiveGranularity(effectiveGranularity)
noteAppOp(op, clientIdentity, message)
} catch (e: Exception) {
Log.w(TAG, "Could not notify appops", e)
true
}
}
fun Context.checkAppOpForEffectiveGranularity(clientIdentity: ClientIdentity, effectiveGranularity: @Granularity Int): Boolean {
return try {
val op = checkAppOpFromEffectiveGranularity(effectiveGranularity)
checkAppOp(op, clientIdentity)
} catch (e: Exception) {
Log.w(TAG, "Could not check appops", e)
true
}
}
fun Context.startAppOpForEffectiveGranularity(clientIdentity: ClientIdentity, effectiveGranularity: @Granularity Int): Boolean {
return try {
val ops = persistAppOpsFromEffectiveGranularity(effectiveGranularity)
startAppOps(ops, clientIdentity)
} catch (e: Exception) {
Log.w(TAG, "Could not start appops", e)
true
}
}
fun Context.finishAppOpForEffectiveGranularity(clientIdentity: ClientIdentity, effectiveGranularity: @Granularity Int) {
try {
val ops = persistAppOpsFromEffectiveGranularity(effectiveGranularity)
finishAppOps(ops, clientIdentity)
} catch (e: Exception) {
Log.w(TAG, "Could not finish appops", e)
}
}
private fun Context.checkAppOp(
op: String,
clientIdentity: ClientIdentity
) = try {
if (SDK_INT >= 29) {
getSystemService<AppOpsManager>()?.unsafeCheckOpNoThrow(op, clientIdentity.uid, clientIdentity.packageName) == AppOpsManager.MODE_ALLOWED
} else {
getSystemService<AppOpsManager>()?.checkOpNoThrow(op, clientIdentity.uid, clientIdentity.packageName) == AppOpsManager.MODE_ALLOWED
}
} catch (e: SecurityException) {
true
}
fun Context.startAppOps(
ops: List<String>,
clientIdentity: ClientIdentity,
message: String? = null
) = ops.all { startAppOp(it, clientIdentity, message) }
fun Context.startAppOp(
op: String,
clientIdentity: ClientIdentity,
message: String? = null
) = try {
if (SDK_INT >= 30 && clientIdentity.attributionTag != null) {
getSystemService<AppOpsManager>()?.startOpNoThrow(op, clientIdentity.uid, clientIdentity.packageName, clientIdentity.attributionTag!!, message)
} else {
getSystemService<AppOpsManager>()?.startOpNoThrow(op, clientIdentity.uid, clientIdentity.packageName)
}
} catch (e: SecurityException) {
if (SDK_INT >= 31) {
getSystemService<AppOpsManager>()?.startProxyOpNoThrow(op, clientIdentity.uid, clientIdentity.packageName, clientIdentity.attributionTag, message)
} else {
AppOpsManager.MODE_ALLOWED
}
} == AppOpsManager.MODE_ALLOWED
fun Context.finishAppOps(
ops: List<String>,
clientIdentity: ClientIdentity
) = ops.forEach { finishAppOp(it, clientIdentity) }
fun Context.finishAppOp(
op: String,
clientIdentity: ClientIdentity
) {
try {
if (SDK_INT >= 30 && clientIdentity.attributionTag != null) {
getSystemService<AppOpsManager>()?.finishOp(op, clientIdentity.uid, clientIdentity.packageName, clientIdentity.attributionTag!!)
} else {
getSystemService<AppOpsManager>()?.finishOp(op, clientIdentity.uid, clientIdentity.packageName)
}
} catch (e: SecurityException) {
if (SDK_INT >= 31) {
getSystemService<AppOpsManager>()?.finishProxyOp(op, clientIdentity.uid, clientIdentity.packageName, clientIdentity.attributionTag)
}
}
}
private fun Context.noteAppOp(
op: String,
clientIdentity: ClientIdentity,
message: String? = null
) = try {
if (SDK_INT >= 30) {
getSystemService<AppOpsManager>()
?.noteOpNoThrow(op, clientIdentity.uid, clientIdentity.packageName, clientIdentity.attributionTag, message) == AppOpsManager.MODE_ALLOWED
} else {
AppOpsManagerCompat.noteOpNoThrow(this, op, clientIdentity.uid, clientIdentity.packageName) == AppOpsManager.MODE_ALLOWED
}
} catch (e: SecurityException) {
if (Binder.getCallingUid() == clientIdentity.uid) {
AppOpsManagerCompat.noteProxyOpNoThrow(this, op, clientIdentity.packageName) == AppOpsManager.MODE_ALLOWED
} else if (SDK_INT >= 29) {
getSystemService<AppOpsManager>()
?.noteProxyOpNoThrow(op, clientIdentity.packageName, clientIdentity.uid) == AppOpsManager.MODE_ALLOWED
} else {
true
}
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.reporting
import android.os.RemoteException
import com.google.android.gms.common.api.CommonStatusCodes
import com.google.android.gms.common.internal.ConnectionInfo
import com.google.android.gms.common.internal.GetServiceRequest
import com.google.android.gms.common.internal.IGmsCallbacks
import org.microg.gms.BaseService
import org.microg.gms.common.GmsService
import org.microg.gms.common.PackageUtils
import org.microg.gms.location.manager.FEATURES
class ReportingAndroidService : BaseService("GmsLocReportingSvc", GmsService.LOCATION_REPORTING) {
@Throws(RemoteException::class)
override fun handleServiceRequest(callback: IGmsCallbacks, request: GetServiceRequest, service: GmsService) {
val packageName = PackageUtils.getAndCheckCallingPackage(this, request.packageName)
?: throw IllegalArgumentException("Missing package name")
callback.onPostInitCompleteWithConnectionInfo(
CommonStatusCodes.SUCCESS,
ReportingServiceInstance(this, packageName),
ConnectionInfo().apply { features = FEATURES }
)
}
}

View file

@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.reporting
import android.accounts.Account
import android.content.Context
import android.os.Parcel
import android.util.Log
import com.google.android.gms.location.reporting.*
import com.google.android.gms.location.reporting.internal.IReportingService
import org.microg.gms.common.GooglePackagePermission
import org.microg.gms.common.PackageUtils
import org.microg.gms.utils.warnOnTransactionIssues
/**
* https://userlocation.googleapis.com/userlocation.UserLocationReportingService/GetApiSettings
* Follow-up: Fill ReportingState based on AccountConfig returned by the interface and persistence processing
*/
//import com.google.android.gms.location.places.PlaceReport;
class ReportingServiceInstance(private val context: Context, private val packageName: String) : IReportingService.Stub() {
override fun getReportingState(account: Account): ReportingState {
Log.d(TAG, "getReportingState")
val state = ReportingState()
if (PackageUtils.callerHasGooglePackagePermission(context, GooglePackagePermission.REPORTING)) {
state.deviceTag = 0
state.allowed = true
}
return state
}
override fun tryOptInAccount(account: Account): Int {
val request = OptInRequest()
request.account = account
return tryOptIn(request)
}
override fun requestUpload(request: UploadRequest): UploadRequestResult {
Log.d(TAG, "requestUpload")
return UploadRequestResult()
}
override fun cancelUploadRequest(l: Long): Int {
Log.d(TAG, "cancelUploadRequest")
return 0
}
// @Override
// public int reportDeviceAtPlace(Account account, PlaceReport report) throws RemoteException {
// Log.d(TAG, "reportDeviceAtPlace");
// return 0;
// }
override fun tryOptIn(request: OptInRequest): Int {
return 0
}
override fun sendData(request: SendDataRequest): Int {
return 0
}
override fun requestPrivateMode(request: UlrPrivateModeRequest): Int {
return 0
}
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean =
warnOnTransactionIssues(code, reply, flags, TAG) { super.onTransact(code, data, reply, flags) }
}

View file

@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.reporting
const val TAG = "LocationReporting"

View file

@ -0,0 +1,72 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.settings
import android.Manifest.permission.*
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager.*
import android.location.LocationManager
import android.location.LocationManager.GPS_PROVIDER
import android.location.LocationManager.NETWORK_PROVIDER
import android.os.Build.VERSION.SDK_INT
import android.provider.Settings
import androidx.core.content.getSystemService
import com.google.android.gms.location.LocationSettingsStates
import org.microg.gms.location.hasNetworkLocationServiceBuiltIn
data class DetailedLocationSettingsStates(
val gpsSystemFeature: Boolean,
val networkLocationSystemFeature: Boolean,
val bluetoothLeSystemFeature: Boolean,
val gpsProviderEnabled: Boolean,
val networkLocationProviderEnabled: Boolean,
val networkLocationProviderBuiltIn: Boolean,
val fineLocationPermission: Boolean,
val coarseLocationPermission: Boolean,
val backgroundLocationPermission: Boolean,
val blePresent: Boolean,
val bleEnabled: Boolean,
val bleScanAlways: Boolean,
val airplaneMode: Boolean,
) {
val gpsPresent: Boolean
get() = gpsSystemFeature
val networkLocationPresent: Boolean
get() = networkLocationSystemFeature || networkLocationProviderBuiltIn
val gpsUsable: Boolean
get() = gpsProviderEnabled
val networkLocationUsable: Boolean
get() = (networkLocationProviderEnabled || networkLocationProviderBuiltIn)
val bleUsable: Boolean
get() = blePresent && (bleEnabled || (bleScanAlways && !airplaneMode))
fun toApi() = LocationSettingsStates(gpsUsable, networkLocationUsable, bleUsable, gpsPresent, networkLocationPresent, blePresent)
}
fun Context.getDetailedLocationSettingsStates(): DetailedLocationSettingsStates {
val bluetoothLeSystemFeature = packageManager.hasSystemFeature(FEATURE_BLUETOOTH_LE)
val locationManager = getSystemService<LocationManager>()
val bluetoothManager = if (bluetoothLeSystemFeature) getSystemService<BluetoothManager>() else null
val bleAdapter = bluetoothManager?.adapter
return DetailedLocationSettingsStates(
gpsSystemFeature = packageManager.hasSystemFeature(FEATURE_LOCATION_GPS),
networkLocationSystemFeature = packageManager.hasSystemFeature(FEATURE_LOCATION_NETWORK),
bluetoothLeSystemFeature = bluetoothLeSystemFeature,
gpsProviderEnabled = locationManager?.isProviderEnabled(GPS_PROVIDER) == true,
networkLocationProviderEnabled = locationManager?.isProviderEnabled(NETWORK_PROVIDER) == true,
networkLocationProviderBuiltIn = hasNetworkLocationServiceBuiltIn(),
fineLocationPermission = packageManager.checkPermission(ACCESS_FINE_LOCATION, packageName) == PERMISSION_GRANTED,
coarseLocationPermission = packageManager.checkPermission(ACCESS_COARSE_LOCATION, packageName) == PERMISSION_GRANTED,
backgroundLocationPermission = if (SDK_INT < 29) true else
packageManager.checkPermission(ACCESS_BACKGROUND_LOCATION, packageName) == PERMISSION_GRANTED,
blePresent = bleAdapter != null,
bleEnabled = bleAdapter?.isEnabled == true,
bleScanAlways = Settings.Global.getInt(contentResolver, "ble_scan_always_enabled", 0) == 1,
airplaneMode = Settings.Global.getInt(contentResolver, Settings.Global.AIRPLANE_MODE_ON, 0) != 0
)
}

View file

@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.settings
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class GoogleLocationSettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
runCatching { startActivity(Intent("android.settings.LOCATION_SOURCE_SETTINGS")) }
finish()
}
}

View file

@ -0,0 +1,229 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.settings
import android.Manifest
import android.app.Activity
import android.content.DialogInterface
import android.content.Intent
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import com.google.android.gms.common.internal.safeparcel.SafeParcelableSerializer
import com.google.android.gms.location.Granularity
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationSettingsRequest
import com.google.android.gms.location.Priority
import org.microg.gms.location.core.R
import org.microg.gms.location.manager.AskPermissionActivity
import org.microg.gms.location.manager.EXTRA_PERMISSIONS
import org.microg.gms.ui.buildAlertDialog
const val ACTION_LOCATION_SETTINGS_CHECKER = "com.google.android.gms.location.settings.CHECK_SETTINGS"
const val EXTRA_ORIGINAL_PACKAGE_NAME = "originalPackageName"
const val EXTRA_SETTINGS_REQUEST = "locationSettingsRequests"
const val EXTRA_REQUESTS = "locationRequests"
const val EXTRA_SETTINGS_STATES = "com.google.android.gms.location.LOCATION_SETTINGS_STATES"
private const val REQUEST_CODE_LOCATION = 120
private const val REQUEST_CODE_PERMISSION = 121
private const val TAG = "LocationSettings"
class LocationSettingsCheckerActivity : Activity(), DialogInterface.OnCancelListener, DialogInterface.OnClickListener {
private var alwaysShow = false
private var needBle = false
private var improvements = emptyList<Improvement>()
private var requests: List<LocationRequest>? = null
private var dialog: AlertDialog? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "LocationSettingsCheckerActivity onCreate")
if (intent.hasExtra(EXTRA_SETTINGS_REQUEST)) {
try {
val request = SafeParcelableSerializer.deserializeFromBytes(intent.getByteArrayExtra(EXTRA_SETTINGS_REQUEST), LocationSettingsRequest.CREATOR)
alwaysShow = request.alwaysShow
needBle = request.needBle
requests = request.requests
} catch (e: Exception) {
Log.w(TAG, e)
}
}
if (requests == null && intent.hasExtra(EXTRA_REQUESTS)) {
try {
val arrayList = intent.getSerializableExtra(EXTRA_REQUESTS) as? ArrayList<*>
requests = arrayList?.map {
SafeParcelableSerializer.deserializeFromBytes(it as ByteArray, LocationRequest.CREATOR)
}
} catch (e: Exception) {
Log.w(TAG, e)
}
}
if (requests == null) {
finishResult(RESULT_CANCELED)
} else {
updateImprovements()
if (improvements.isEmpty()) {
finishResult(RESULT_OK)
} else {
showDialog()
}
}
}
override fun onResume() {
super.onResume()
checkImprovements()
}
override fun onPause() {
dialog?.dismiss()
super.onPause()
}
enum class Improvement {
GPS, NLP, GPS_AND_NLP, WIFI, WIFI_SCANNING, BLUETOOTH, BLE_SCANNING, PERMISSIONS, DATA_SOURCE
}
private fun updateImprovements() {
val states = getDetailedLocationSettingsStates()
val requests = requests?.map {
// TODO: We assume fine for permission level granularity here
it.priority to (if (it.granularity == Granularity.GRANULARITY_PERMISSION_LEVEL) Granularity.GRANULARITY_FINE else it.granularity)
}.orEmpty()
val gpsRequested = requests.any { it.first == Priority.PRIORITY_HIGH_ACCURACY && it.second == Granularity.GRANULARITY_FINE }
val networkLocationRequested = requests.any { it.first <= Priority.PRIORITY_LOW_POWER && it.second >= Granularity.GRANULARITY_COARSE }
improvements = listOfNotNull(
Improvement.GPS_AND_NLP.takeIf { gpsRequested && !states.gpsUsable || networkLocationRequested && !states.networkLocationUsable },
Improvement.PERMISSIONS.takeIf { !states.coarseLocationPermission || !states.fineLocationPermission },
)
}
private fun showDialog() {
val dialog = this.dialog?.takeIf { it.isShowing } ?: buildAlertDialog()
.setOnCancelListener(this)
.setPositiveButton(R.string.location_settings_dialog_btn_sure, this)
.setNegativeButton(R.string.location_settings_dialog_btn_cancel, this)
.create()
.apply { setCanceledOnTouchOutside(false) }
.also { this@LocationSettingsCheckerActivity.dialog = it }
val view = layoutInflater.inflate(R.layout.location_settings_dialog, null)
view.findViewById<TextView>(R.id.message_title)
.setText(if (alwaysShow) R.string.location_settings_dialog_message_title_to_continue else R.string.location_settings_dialog_message_title_for_better_experience)
val messages = view.findViewById<LinearLayout>(R.id.messages)
for ((messageIndex, improvement) in improvements.withIndex()) {
val item = layoutInflater.inflate(R.layout.location_settings_dialog_item, messages, false)
item.findViewById<TextView>(android.R.id.text1).text = when (improvement) {
Improvement.GPS_AND_NLP -> getString(R.string.location_settings_dialog_message_location_services_gps_and_nlp)
Improvement.PERMISSIONS -> getString(R.string.location_settings_dialog_message_grant_permissions)
else -> {
Log.w(TAG, "Unsupported improvement: $improvement")
""
}
}
item.findViewById<ImageView>(android.R.id.icon).setImageDrawable(
when (improvement) {
Improvement.GPS_AND_NLP -> ContextCompat.getDrawable(this, R.drawable.ic_gps)
Improvement.PERMISSIONS -> ContextCompat.getDrawable(this, R.drawable.ic_location)
else -> {
Log.w(TAG, "Unsupported improvement: $improvement")
null
}
}
)
messages.addView(item, messageIndex + 1)
}
dialog.setView(view)
if (!dialog.isShowing) dialog.show()
}
private fun handleContinue() {
val improvement = improvements.firstOrNull() ?: return finishResult(RESULT_OK)
when (improvement) {
Improvement.PERMISSIONS -> {
val intent = Intent(this, AskPermissionActivity::class.java).apply {
putExtra(EXTRA_PERMISSIONS, locationPermissions.toTypedArray())
}
startActivityForResult(intent, REQUEST_CODE_PERMISSION)
return
}
Improvement.GPS, Improvement.NLP, Improvement.GPS_AND_NLP -> {
// TODO: If we have permission to, just activate directly
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
startActivityForResult(intent, REQUEST_CODE_LOCATION)
return // We will continue from onActivityResult
}
else -> {
Log.w(TAG, "Unsupported improvement: $improvement")
}
}
updateImprovements()
handleContinue()
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_LOCATION || requestCode == REQUEST_CODE_PERMISSION) {
checkImprovements()
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private fun checkImprovements() {
// Check if we improved, if so continue, otherwise show dialog again
val oldImprovements = improvements
updateImprovements()
if (oldImprovements == improvements) {
showDialog()
} else {
handleContinue()
}
}
private fun finishResult(resultCode: Int) {
if (dialog?.isShowing == true) dialog?.dismiss()
val states = getDetailedLocationSettingsStates().toApi()
setResult(resultCode, Intent().apply {
putExtra(EXTRA_SETTINGS_STATES, SafeParcelableSerializer.serializeToBytes(states))
})
finish()
}
override fun onBackPressed() {
finishResult(RESULT_CANCELED)
}
override fun onCancel(dialog: DialogInterface?) {
finishResult(RESULT_CANCELED)
}
override fun onClick(dialog: DialogInterface?, which: Int) {
Log.d(TAG, "Not yet implemented: onClick")
when (which) {
DialogInterface.BUTTON_NEGATIVE -> finishResult(RESULT_CANCELED)
DialogInterface.BUTTON_POSITIVE -> handleContinue()
}
}
companion object {
private val locationPermissions = listOfNotNull(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
if (SDK_INT >= 29) Manifest.permission.ACCESS_BACKGROUND_LOCATION else null
)
}
}

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2024 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.ui
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import org.microg.gms.location.ACTION_CONFIGURATION_REQUIRED
import org.microg.gms.location.CONFIGURATION_FIELD_ONLINE_SOURCE
import org.microg.gms.location.EXTRA_CONFIGURATION
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import org.microg.gms.location.core.R
class ConfigurationRequiredReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == ACTION_CONFIGURATION_REQUIRED) {
if (SDK_INT >= 23 && context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
val channel = NotificationChannelCompat.Builder("location", NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(context.getString(R.string.service_name_location)).build()
NotificationManagerCompat.from(context).createNotificationChannel(channel)
val notification = NotificationCompat.Builder(context, channel.id)
.setContentTitle(context.getText(R.string.notification_config_required_title))
.setSmallIcon(R.drawable.ic_location)
.setAutoCancel(true)
when (intent.getStringExtra(EXTRA_CONFIGURATION)) {
CONFIGURATION_FIELD_ONLINE_SOURCE -> {
val notifyIntent = Intent(Intent.ACTION_VIEW, Uri.parse("x-gms-settings://location"))
.apply {
`package` = context.packageName
putExtra(EXTRA_CONFIGURATION, CONFIGURATION_FIELD_ONLINE_SOURCE)
}
notification.setContentText(context.getText(R.string.notification_config_required_text_online_sources))
.setStyle(NotificationCompat.BigTextStyle())
.setContentIntent(PendingIntentCompat.getActivity(context, CONFIGURATION_FIELD_ONLINE_SOURCE.hashCode(), notifyIntent, 0, true))
}
else -> return
}
NotificationManagerCompat.from(context).notify(CONFIGURATION_FIELD_ONLINE_SOURCE.hashCode(), notification.build())
}
}
}

View file

@ -0,0 +1,86 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.ui
import android.os.Bundle
import android.text.format.DateUtils
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.microg.gms.location.manager.LocationAppsDatabase
import org.microg.gms.ui.AppIconPreference
import org.microg.gms.ui.getApplicationInfoIfExists
import org.microg.gms.ui.navigate
import org.microg.gms.location.core.R
class LocationAllAppsFragment : PreferenceFragmentCompat() {
private lateinit var progress: Preference
private lateinit var locationApps: PreferenceCategory
private lateinit var database: LocationAppsDatabase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
database = LocationAppsDatabase(requireContext())
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.preferences_location_all_apps)
progress = preferenceScreen.findPreference("pref_location_apps_all_progress") ?: progress
locationApps = preferenceScreen.findPreference("prefcat_location_apps") ?: locationApps
}
override fun onResume() {
super.onResume()
updateContent()
}
override fun onPause() {
super.onPause()
database.close()
}
private fun updateContent() {
lifecycleScope.launchWhenResumed {
val context = requireContext()
val apps = withContext(Dispatchers.IO) {
val res = database.listAppsByAccessTime().map { app ->
app to context.packageManager.getApplicationInfoIfExists(app.first)
}.map { (app, applicationInfo) ->
val pref = AppIconPreference(context)
pref.title = applicationInfo?.loadLabel(context.packageManager) ?: app.first
pref.summary = getString(R.string.location_app_last_access_at, DateUtils.getRelativeTimeSpanString(app.second))
pref.icon = applicationInfo?.loadIcon(context.packageManager) ?: AppCompatResources.getDrawable(context, android.R.mipmap.sym_def_app_icon)
pref.onPreferenceClickListener = Preference.OnPreferenceClickListener {
findNavController().navigate(requireContext(), R.id.openLocationAppDetailsFromAll, bundleOf("package" to app.first))
true
}
pref.key = "pref_location_app_" + app.first
pref
}.sortedBy {
it.title.toString().toLowerCase()
}.mapIndexed { idx, pair ->
pair.order = idx
pair
}
database.close()
res
}
locationApps.removeAll()
locationApps.isVisible = true
for (app in apps) {
locationApps.addPreference(app)
}
progress.isVisible = false
}
}
}

View file

@ -0,0 +1,132 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.location.Geocoder
import android.net.Uri
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.text.format.DateUtils
import android.util.Log
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.microg.gms.location.core.R
import org.microg.gms.location.manager.LocationAppsDatabase
import org.microg.gms.ui.AppHeadingPreference
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
class LocationAppFragment : PreferenceFragmentCompat() {
private lateinit var appHeadingPreference: AppHeadingPreference
private lateinit var lastLocationCategory: PreferenceCategory
private lateinit var lastLocation: Preference
private lateinit var lastLocationMap: LocationMapPreference
private lateinit var forceCoarse: TwoStatePreference
private lateinit var database: LocationAppsDatabase
private val packageName: String?
get() = arguments?.getString("package")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
database = LocationAppsDatabase(requireContext())
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.preferences_location_app_details)
}
@SuppressLint("RestrictedApi")
override fun onBindPreferences() {
appHeadingPreference = preferenceScreen.findPreference("pref_location_app_heading") ?: appHeadingPreference
lastLocationCategory = preferenceScreen.findPreference("prefcat_location_app_last_location") ?: lastLocationCategory
lastLocation = preferenceScreen.findPreference("pref_location_app_last_location") ?: lastLocation
lastLocationMap = preferenceScreen.findPreference("pref_location_app_last_location_map") ?: lastLocationMap
forceCoarse = preferenceScreen.findPreference("pref_location_app_force_coarse") ?: forceCoarse
forceCoarse.setOnPreferenceChangeListener { _, newValue ->
packageName?.let { database.setForceCoarse(it, newValue as Boolean); true } == true
}
}
override fun onResume() {
super.onResume()
updateContent()
}
override fun onPause() {
super.onPause()
database.close()
}
fun Double.toStringWithDigits(digits: Int): String {
val s = this.toString()
val i = s.indexOf('.')
if (i <= 0 || s.length - i - 1 < digits) return s
if (digits == 0) return s.substring(0, i)
return s.substring(0, s.indexOf('.') + digits + 1)
}
fun updateContent() {
val context = requireContext()
lifecycleScope.launchWhenResumed {
appHeadingPreference.packageName = packageName
forceCoarse.isChecked = packageName?.let { database.getForceCoarse(it) } == true
val location = packageName?.let { database.getAppLocation(it) }
if (location != null) {
lastLocationCategory.isVisible = true
lastLocation.title = DateUtils.getRelativeTimeSpanString(location.time)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("geo:${location.latitude},${location.longitude}"))
if (context.packageManager.queryIntentActivities(intent, 0).isNotEmpty()) {
lastLocation.intent = intent
} else {
lastLocation.isSelectable = false
}
lastLocationMap.location = location
val address = try {
if (SDK_INT > 33) {
suspendCoroutine { continuation ->
try {
Geocoder(context).getFromLocation(location.latitude, location.longitude, 1) {
continuation.resume(it.firstOrNull())
}
} catch (e: Exception) {
continuation.resumeWithException(e)
}
}
} else {
withContext(Dispatchers.IO) { Geocoder(context).getFromLocation(location.latitude, location.longitude, 1)?.firstOrNull() }
}
} catch (e: Exception) {
Log.w(TAG, e)
null
}
if (address != null) {
val addressLine = StringBuilder()
var i = 0
addressLine.append(address.getAddressLine(i))
while (addressLine.length < 32 && address.maxAddressLineIndex > i) {
i++
addressLine.append(", ")
addressLine.append(address.getAddressLine(i))
}
lastLocation.summary = addressLine.toString()
} else {
lastLocation.summary = "${location.latitude.toStringWithDigits(6)}, ${location.longitude.toStringWithDigits(6)}"
}
} else {
lastLocationCategory.isVisible = false
}
}
}
}

View file

@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.ui
import android.content.Context
import android.location.Location
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams
import android.widget.FrameLayout
import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMapOptions
import com.google.android.gms.maps.MapView
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.Circle
import com.google.android.gms.maps.model.CircleOptions
import com.google.android.gms.maps.model.LatLng
import org.microg.gms.location.core.R
import org.microg.gms.ui.resolveColor
import kotlin.math.log2
class LocationMapPreference : Preference {
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context) : super(context)
init {
layoutResource = R.layout.preference_full_container
}
var location: Location? = null
set(value) {
field = value
notifyChanged()
}
private var mapView: View? = null
private var circle1: Any? = null
private var circle2: Any? = null
override fun onBindViewHolder(holder: PreferenceViewHolder) {
super.onBindViewHolder(holder)
holder.isDividerAllowedAbove = false
holder.isDividerAllowedBelow = false
if (location != null) {
if (isAvailable) {
val latLng = LatLng(location!!.latitude, location!!.longitude)
val camera = CameraPosition.fromLatLngZoom(latLng, (21 - log2(location!!.accuracy)).coerceIn(2f, 22f))
val container = holder.itemView as ViewGroup
if (mapView == null) {
val options = GoogleMapOptions().liteMode(true).scrollGesturesEnabled(false).zoomGesturesEnabled(false).camera(camera)
mapView = MapView(context, options)
mapView?.layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, (height * context.resources.displayMetrics.density).toInt())
container.addView(mapView)
(mapView as MapView).onCreate(null)
} else {
(mapView as MapView).getMapAsync {
it.moveCamera(CameraUpdateFactory.newCameraPosition(camera))
}
}
(circle1 as? Circle?)?.remove()
(circle2 as? Circle?)?.remove()
(mapView as MapView).getMapAsync {
val strokeColor = (context.resolveColor(androidx.appcompat.R.attr.colorAccent) ?: 0xff009688L.toInt())
val fillColor = strokeColor and 0x60ffffff
circle1 = it.addCircle(CircleOptions().center(latLng).radius(location!!.accuracy.toDouble()).fillColor(fillColor).strokeWidth(1f).strokeColor(strokeColor))
circle2 = it.addCircle(CircleOptions().center(latLng).radius(location!!.accuracy.toDouble() * 2).fillColor(fillColor).strokeWidth(1f).strokeColor(strokeColor))
}
} else {
Log.d(TAG, "MapView not available")
}
} else if (mapView != null) {
(mapView as MapView).onDestroy()
(mapView?.parent as? ViewGroup?)?.removeView(mapView)
circle1 = null
circle2 = null
mapView = null
}
}
override fun onDetached() {
super.onDetached()
if (mapView != null) {
(mapView as MapView).onDestroy()
circle1 = null
circle2 = null
mapView = null
}
}
companion object {
const val height = 200f
val isAvailable: Boolean
get() = try {
Class.forName("com.google.android.gms.maps.MapView")
true
} catch (e: ClassNotFoundException) {
false
}
}
}

View file

@ -0,0 +1,384 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.ui
import android.app.Activity
import android.app.Dialog
import android.content.Intent
import android.location.LocationManager
import android.net.Uri
import android.os.*
import android.os.Build.VERSION.SDK_INT
import android.text.Html
import android.text.SpannableStringBuilder
import android.text.method.LinkMovementMethod
import android.util.Log
import android.view.*
import android.view.Menu.NONE
import android.widget.*
import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import androidx.core.view.setPadding
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import androidx.preference.Preference
import androidx.preference.PreferenceCategory
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.TwoStatePreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.microg.gms.location.*
import org.microg.gms.location.core.R
import org.microg.gms.location.manager.LocationAppsDatabase
import org.microg.gms.location.network.OnlineSource
import org.microg.gms.location.network.effectiveEndpoint
import org.microg.gms.location.network.onlineSource
import org.microg.gms.ui.AppIconPreference
import org.microg.gms.ui.buildAlertDialog
import org.microg.gms.ui.getApplicationInfoIfExists
import org.microg.gms.ui.navigate
private const val REQUEST_CODE_IMPORT_FILE = 5715515
class LocationPreferencesFragment : PreferenceFragmentCompat() {
private lateinit var locationApps: PreferenceCategory
private lateinit var locationAppsAll: Preference
private lateinit var locationAppsNone: Preference
private lateinit var networkProviderCategory: PreferenceCategory
private lateinit var wifiIchnaea: TwoStatePreference
private lateinit var wifiMoving: TwoStatePreference
private lateinit var wifiLearning: TwoStatePreference
private lateinit var cellIchnaea: TwoStatePreference
private lateinit var cellLearning: TwoStatePreference
private lateinit var nominatim: TwoStatePreference
private lateinit var database: LocationAppsDatabase
init {
setHasOptionsMenu(true)
}
companion object {
private const val MENU_ICHNAEA_URL = Menu.FIRST
private const val MENU_IMPORT_EXPORT = Menu.FIRST + 1
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
if (requireContext().hasNetworkLocationServiceBuiltIn()) {
menu.add(NONE, MENU_ICHNAEA_URL, NONE, R.string.pref_location_source_title)
menu.add(NONE, MENU_IMPORT_EXPORT, NONE, R.string.pref_location_import_export_title)
}
super.onCreateOptionsMenu(menu, inflater)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == MENU_ICHNAEA_URL) {
openOnlineSourceSelector()
return true
}
if (item.itemId == MENU_IMPORT_EXPORT) {
openImportExportDialog()
return true
}
return super.onOptionsItemSelected(item)
}
private val messenger by lazy {
Messenger(object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
try {
when (msg.data.getString(EXTRA_DIRECTION)) {
DIRECTION_EXPORT -> {
val name = msg.data.getString(EXTRA_NAME)
val fileUri = msg.data.getParcelable<Uri>(EXTRA_URI)
if (fileUri != null) {
val sendIntent: Intent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
type = "application/vnd.microg.location.$name+csv+gzip"
}
startActivity(Intent.createChooser(sendIntent, null))
}
currentDialog?.dismiss()
}
DIRECTION_IMPORT -> {
val counter = msg.arg1
Toast.makeText(requireContext(), getString(R.string.location_data_import_result_toast, counter), Toast.LENGTH_SHORT).show()
currentDialog?.dismiss()
}
}
} catch (e: Exception) {
Log.w(TAG, e)
}
}
})
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_IMPORT_FILE) {
if (resultCode == Activity.RESULT_OK && data?.data != null) {
val intent = Intent(ACTION_NETWORK_IMPORT_EXPORT)
intent.`package` = requireContext().packageName
intent.putExtra(EXTRA_DIRECTION, DIRECTION_IMPORT)
intent.putExtra(EXTRA_MESSENGER, messenger)
intent.putExtra(EXTRA_URI, data.data)
requireContext().startService(intent)
} else {
currentDialog?.dismiss()
}
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
private val Int.dp
get() = (this * resources.displayMetrics.density).toInt()
private var currentDialog: Dialog? = null
private fun openImportExportDialog() {
val listView = ListView(requireContext()).apply {
setPadding(8.dp, 16.dp, 8.dp, 16.dp)
adapter = ArrayAdapter<String>(requireContext(), android.R.layout.simple_list_item_1).apply {
add(requireContext().getString(R.string.location_data_export_wifi_title))
add(requireContext().getString(R.string.location_data_export_cell_title))
add(requireContext().getString(R.string.location_data_import_title))
}
}
val progress = ProgressBar(requireContext()).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
setPadding(20.dp)
isIndeterminate = true
visibility = View.GONE
}
val view = FrameLayout(requireContext()).apply {
addView(listView)
addView(progress)
}
currentDialog = requireContext().buildAlertDialog()
.setTitle(R.string.pref_location_import_export_title)
.setView(view)
.show()
listView.setOnItemClickListener { _, _, position, _ ->
if (position == 0 || position == 1) {
val intent = Intent(ACTION_NETWORK_IMPORT_EXPORT)
intent.`package` = requireContext().packageName
intent.putExtra(EXTRA_DIRECTION, DIRECTION_EXPORT)
intent.putExtra(EXTRA_NAME, if (position == 0) NAME_WIFI else NAME_CELL)
intent.putExtra(EXTRA_MESSENGER, messenger)
requireContext().startService(intent)
} else if (position == 2) {
val openIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
startActivityForResult(openIntent, REQUEST_CODE_IMPORT_FILE)
}
listView.visibility = View.INVISIBLE
progress.visibility = View.VISIBLE
currentDialog?.setCancelable(false)
}
}
private fun openOnlineSourceSelector(callback: () -> Unit = {}) {
val view = LinearLayout(requireContext())
view.setPadding(0, 16.dp, 0, 0)
view.orientation = LinearLayout.VERTICAL
val settings = LocationSettings(requireContext())
val currentSourceId = settings.onlineSource?.id
val unselectHandlerMap = mutableMapOf<String, () -> Unit>()
var selectedSourceId = currentSourceId
val customView = layoutInflater.inflate(R.layout.preference_location_custom_url, null)
customView.findViewById<EditText>(android.R.id.edit).setText(settings.customEndpoint)
customView.visibility = View.GONE
for (source in OnlineSource.ALL) {
val title = when {
source.name != null -> source.name
source.id == OnlineSource.ID_CUSTOM -> getText(R.string.pref_location_custom_source_title)
else -> source.id
}
val sourceDescription = source.host.takeIf { source.name != it }
val sourceTerms = source.terms?.let { Html.fromHtml("<a href=\"${it}\">${getText(R.string.pref_location_source_terms)}</a>") }
val description = when {
sourceDescription != null && sourceTerms != null -> SpannableStringBuilder().append(sourceDescription).append(" · ").append(sourceTerms)
sourceDescription != null -> sourceDescription
sourceTerms != null -> sourceTerms
else -> null
}
val subView = layoutInflater.inflate(R.layout.preference_location_online_source, null)
subView.findViewById<TextView>(android.R.id.title).text = title
if (description != null) {
subView.findViewById<TextView>(android.R.id.text1).text = description
if (sourceTerms != null) subView.findViewById<TextView>(android.R.id.text1).movementMethod = LinkMovementMethod.getInstance()
} else {
subView.findViewById<TextView>(android.R.id.text1).visibility = View.GONE
}
if (source.suggested) subView.findViewById<View>(R.id.suggested_tag).visibility = View.VISIBLE
unselectHandlerMap[source.id] = {
subView.findViewById<ImageView>(android.R.id.button1).setImageResource(org.microg.gms.base.core.R.drawable.ic_radio_unchecked)
if (source.id == OnlineSource.ID_CUSTOM) customView.visibility = View.GONE
}
val selectedHandler = {
for (entry in unselectHandlerMap) {
if (entry.key != source.id) {
entry.value.invoke()
}
}
if (source.id == OnlineSource.ID_CUSTOM) customView.visibility = View.VISIBLE
subView.findViewById<ImageView>(android.R.id.button1).setImageResource(org.microg.gms.base.core.R.drawable.ic_radio_checked)
selectedSourceId = source.id
}
if (currentSourceId == source.id) selectedHandler.invoke()
subView.setOnClickListener { selectedHandler.invoke() }
view.addView(subView)
}
view.addView(customView)
requireContext().buildAlertDialog()
.setTitle(R.string.pref_location_source_title)
.setPositiveButton(android.R.string.ok) { _, _ ->
if (selectedSourceId == OnlineSource.ID_CUSTOM) {
settings.customEndpoint = customView.findViewById<EditText>(android.R.id.edit).text.toString()
}
settings.onlineSourceId = selectedSourceId
}
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.setOnDismissListener { callback() }
.setView(view)
.show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
database = LocationAppsDatabase(requireContext())
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.preferences_location)
locationApps = preferenceScreen.findPreference("prefcat_location_apps") ?: locationApps
locationAppsAll = preferenceScreen.findPreference("pref_location_apps_all") ?: locationAppsAll
locationAppsNone = preferenceScreen.findPreference("pref_location_apps_none") ?: locationAppsNone
networkProviderCategory = preferenceScreen.findPreference("prefcat_location_network_provider") ?: networkProviderCategory
wifiIchnaea = preferenceScreen.findPreference("pref_location_wifi_mls_enabled") ?: wifiIchnaea
wifiMoving = preferenceScreen.findPreference("pref_location_wifi_moving_enabled") ?: wifiMoving
wifiLearning = preferenceScreen.findPreference("pref_location_wifi_learning_enabled") ?: wifiLearning
cellIchnaea = preferenceScreen.findPreference("pref_location_cell_mls_enabled") ?: cellIchnaea
cellLearning = preferenceScreen.findPreference("pref_location_cell_learning_enabled") ?: cellLearning
nominatim = preferenceScreen.findPreference("pref_geocoder_nominatim_enabled") ?: nominatim
locationAppsAll.setOnPreferenceClickListener {
findNavController().navigate(requireContext(), R.id.openAllLocationApps)
true
}
fun configureChangeListener(preference: TwoStatePreference, listener: (Boolean) -> Unit) {
preference.setOnPreferenceChangeListener { _, newValue ->
listener(newValue as Boolean)
true
}
}
configureChangeListener(wifiIchnaea) {
val settings = LocationSettings(requireContext())
if (!it || settings.effectiveEndpoint != null) {
settings.wifiIchnaea = it
} else {
openOnlineSourceSelector {
if (settings.effectiveEndpoint != null) {
settings.wifiIchnaea = true
} else {
wifiIchnaea.isChecked = false
}
}
}
}
configureChangeListener(wifiMoving) { LocationSettings(requireContext()).wifiMoving = it }
configureChangeListener(wifiLearning) { LocationSettings(requireContext()).wifiLearning = it }
configureChangeListener(cellIchnaea) {
val settings = LocationSettings(requireContext())
if (!it || settings.effectiveEndpoint != null) {
settings.cellIchnaea = it
} else {
openOnlineSourceSelector {
if (settings.effectiveEndpoint != null) {
settings.cellIchnaea = true
} else {
cellIchnaea.isChecked = false
}
}
}
}
configureChangeListener(cellLearning) { LocationSettings(requireContext()).cellLearning = it }
configureChangeListener(nominatim) { LocationSettings(requireContext()).geocoderNominatim = it }
networkProviderCategory.isVisible = requireContext().hasNetworkLocationServiceBuiltIn()
wifiLearning.isVisible =
SDK_INT >= 17 && requireContext().getSystemService<LocationManager>()?.allProviders.orEmpty().contains(LocationManager.GPS_PROVIDER)
cellLearning.isVisible =
SDK_INT >= 17 && requireContext().getSystemService<LocationManager>()?.allProviders.orEmpty().contains(LocationManager.GPS_PROVIDER)
}
override fun onResume() {
super.onResume()
runCatching { updateContent() }.onFailure { database.close() }
arguments?.let {
if (it.containsKey(NavController.KEY_DEEP_LINK_INTENT)) {
val intent = it.getParcelable<Intent>(NavController.KEY_DEEP_LINK_INTENT)
when (intent?.getStringExtra(EXTRA_CONFIGURATION)) {
CONFIGURATION_FIELD_ONLINE_SOURCE -> openOnlineSourceSelector()
}
}
}
}
override fun onPause() {
super.onPause()
database.close()
}
private fun updateContent() {
lifecycleScope.launchWhenResumed {
val context = requireContext()
wifiIchnaea.isChecked = LocationSettings(context).wifiIchnaea
wifiMoving.isChecked = LocationSettings(context).wifiMoving
wifiLearning.isChecked = LocationSettings(context).wifiLearning
cellIchnaea.isChecked = LocationSettings(context).cellIchnaea
cellLearning.isChecked = LocationSettings(context).cellLearning
nominatim.isChecked = LocationSettings(context).geocoderNominatim
val (apps, showAll) = withContext(Dispatchers.IO) {
val apps = database.listAppsByAccessTime()
val res = apps.map { app ->
app to context.packageManager.getApplicationInfoIfExists(app.first)
}.mapNotNull { (app, info) ->
if (info == null) null else app to info
}.take(3).mapIndexed { idx, (app, applicationInfo) ->
val pref = AppIconPreference(context)
pref.order = idx
pref.applicationInfo = applicationInfo
pref.onPreferenceClickListener = Preference.OnPreferenceClickListener {
findNavController().navigate(requireContext(), R.id.openLocationAppDetails, bundleOf("package" to app.first))
true
}
pref.key = "pref_location_app_" + app.first
pref
}.let { it to (it.size < apps.size) }
database.close()
res
}
locationAppsAll.isVisible = showAll
locationApps.removeAll()
for (app in apps) {
locationApps.addPreference(app)
}
if (showAll) {
locationApps.addPreference(locationAppsAll)
} else if (apps.isEmpty()) {
locationApps.addPreference(locationAppsNone)
}
}
}
}

View file

@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2023 microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/
package org.microg.gms.location.ui
const val TAG = "LocationUi"

View file

@ -0,0 +1,16 @@
<!--
~ SPDX-FileCopyrightText: 2019 The Android Open Source Project
~ SPDX-FileCopyrightText: 2024 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<vector android:height="24dp"
android:tint="?attr/colorAccent"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94L13,1h-2v2.06C6.83,3.52 3.52,6.83 3.06,11L1,11v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94L11,23h2v-2.06c4.17,-0.46 7.48,-3.77 7.94,-7.94L23,13v-2h-2.06zM12,19c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z" />
</vector>

View file

@ -0,0 +1,16 @@
<!--
~ SPDX-FileCopyrightText: 2019 The Android Open Source Project
~ SPDX-FileCopyrightText: 2024 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<vector android:height="24dp"
android:tint="?attr/colorAccent"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2024 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:id="@+id/message_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
android:textStyle="bold"
android:paddingLeft="24dp"
android:paddingRight="24dp"
style="?android:textAppearanceMedium" />
<TextView
android:id="@+id/message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:paddingLeft="24dp"
android:paddingRight="24dp"
android:text="@string/location_settings_dialog_message_title_to_continue"
style="?android:attr/textAppearanceMedium" />
<LinearLayout
android:id="@+id/messages"
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingLeft="24dp"
android:paddingRight="24dp">
<TextView
android:layout_gravity="top"
android:id="@+id/details_start"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/location_settings_dialog_message_details_start_paragraph" />
<TextView
android:layout_gravity="bottom"
android:id="@+id/details_end"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/location_settings_dialog_message_details_end_paragraph" />
</LinearLayout>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2024 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp">
<ImageView
android:layout_gravity="top"
android:id="@android:id/icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="0dp"
android:paddingEnd="16dp" />
<TextView
android:layout_gravity="top"
android:id="@android:id/text1"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2023 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:paddingLeft="16dip"
android:paddingRight="16dip"
android:layout_height="wrap_content">
</FrameLayout>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2024 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="24dp"
android:paddingRight="24dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_location_custom_url_summary" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/pref_location_custom_url_details" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/pref_location_custom_url_input_hint"
app:placeholderText="https://example.com/?key=example">
<com.google.android.material.textfield.TextInputEditText
android:id="@android:id/edit"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</ScrollView>

View file

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2024 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp"
android:orientation="horizontal"
android:clickable="true">
<ImageView
android:src="@drawable/ic_radio_unchecked"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="@android:id/text1"
android:id="@android:id/button1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:padding="12dp" />
<TextView
android:id="@android:id/title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceListItem"
app:layout_constraintStart_toEndOf="@android:id/button1"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
android:layout_marginStart="4dp"
tools:text="Positon" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/suggested_tag"
android:visibility="gone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintStart_toEndOf="@android:id/title"
app:layout_constraintTop_toTopOf="@android:id/title"
app:layout_constraintBottom_toBottomOf="@android:id/title"
android:layout_marginStart="4dp"
android:measureAllChildren="false"
tools:visibility="visible">
<View
android:background="?attr/colorAccent"
android:alpha="0.2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:layout_width="0dip"
android:layout_height="0dip" />
<TextView
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:paddingHorizontal="4dp"
android:paddingVertical="2dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/pref_location_source_suggested"
android:textAppearance="?attr/textAppearanceListItemSecondary"
android:textColor="?attr/colorAccent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@android:id/text1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@android:id/title"
app:layout_constraintStart_toStartOf="@android:id/title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
android:layout_marginBottom="4dp"
android:textAppearance="?attr/textAppearanceListItemSecondary"
tools:text="positon.xyz · Terms of Use" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ SPDX-FileCopyrightText: 2023 microG Project Team
~ SPDX-License-Identifier: Apache-2.0
-->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
app:startDestination="@id/locationPreferencesFragment"
android:id="@+id/nav_location">
<fragment
android:id="@+id/locationPreferencesFragment"
android:name="org.microg.gms.location.ui.LocationPreferencesFragment"
android:label="@string/service_name_location">
<deepLink
app:uri="x-gms-settings://location" />
<action
android:id="@+id/openAllLocationApps"
app:destination="@id/locationAllAppsPreferencesFragment" />
<action
android:id="@+id/openLocationAppDetails"
app:destination="@id/locationAppFragment" />
</fragment>
<fragment
android:id="@+id/locationAllAppsPreferencesFragment"
android:name="org.microg.gms.location.ui.LocationAllAppsFragment"
android:label="@string/fragment_location_apps_title">
<action
android:id="@+id/openLocationAppDetailsFromAll"
app:destination="@id/locationAppFragment" />
</fragment>
<fragment
android:id="@+id/locationAppFragment"
android:name="org.microg.gms.location.ui.LocationAppFragment"
android:label="@string/fragment_location_apps_title">
<argument
android:name="package"
app:argType="string" />
</fragment>
</navigation>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="service_name_location">الموقع</string>
<string name="prefcat_location_apps_title">الوصول الأخير</string>
<string name="prefcat_location_cell_title">موقع شبكة الجوال</string>
<string name="prefcat_geocoder_title">محلل العناوين</string>
<string name="prefcat_location_wifi_title">موقع شبكة الـ Wi-Fi</string>
<string name="pref_location_wifi_online_enabled_title">طلب من خدمة عبر الإنترنت</string>
<string name="pref_location_wifi_online_enabled_summary">حصول على موقع شبكة الـ Wi-Fi من خدمة تحديد الموقع عبر الإنترنت.</string>
<string name="pref_location_wifi_moving_enabled_title">طلب من نقطة الاتصال</string>
<string name="pref_location_wifi_learning_enabled_summary">تخزين مواقع الـ Wi-Fi محلياً عند استخدام GPS.</string>
<string name="pref_location_wifi_learning_enabled_title">تذكُّر من GPS</string>
<string name="pref_location_cell_online_enabled_title">طلب من خدمة عبر الإنترنت</string>
<string name="pref_location_cell_online_enabled_summary">حصول على مواقع الأبراج الخلوية لشبكة الجوال من خدمة تحديد الموقع من خدمة عبر اﻹنترنت.</string>
<string name="pref_location_cell_learning_enabled_title">تذكُّر من GPS</string>
<string name="pref_location_cell_learning_enabled_summary">تخزين مواقع شبكة الجوال محلياً عند استخدام GPS.</string>
<string name="pref_geocoder_nominatim_enabled_title">استخدام نوميناتيم</string>
<string name="pref_geocoder_nominatim_enabled_summary">حلّ العناوين باستخدام خدمة نوميناتيم من خريطة الشارع المفتوحة.</string>
<string name="fragment_location_apps_title">تطبيقات ذات إمكانية الوصول إلى الموقع</string>
<string name="location_app_last_access_at">آخر وصول: <xliff:g example="Yesterday, 02:20 PM">%1$s</xliff:g></string>
<string name="pref_location_app_force_coarse_title">استخدام الموقع التقريبي دائمًا</string>
<string name="pref_location_app_force_coarse_summary">قم دائمًا بتقديم المواقع التقريبي لهذا التطبيق، مع تجاهل مستوى الإذن الخاص به.</string>
<string name="location_settings_dialog_message_title_for_better_experience">للحصول على تجربة أفضل، قم بتشغيل موقع الجهاز، والذي يستخدم خدمة تحديد الموقع لمايكرو-جي</string>
<string name="location_settings_dialog_message_title_to_continue">للمتابعة، قم بتشغيل موقع الجهاز، والذي يستخدم خدمة تحديد الموقع لمايكرو-جي</string>
<string name="location_settings_dialog_message_details_start_paragraph">سيحتاج جهازك إلى:</string>
<string name="location_settings_dialog_message_location_services_gps_and_nlp">استخدام GPS، وWi-Fi، وشبكة الجوال، والمستشعرات</string>
<string name="location_settings_dialog_message_grant_permissions">منح أذونات الموقع لخدمة مايكرو-جي</string>
<string name="location_settings_dialog_btn_cancel">لا شكرًا</string>
<string name="location_settings_dialog_btn_sure">موافق</string>
<string name="location_settings_dialog_message_gls_consent">استخدام خدمة تحديد الموقع لمايكرو-جي؛ كجزء من هذه الخدمة، قد يقوم مايكرو-جي بجمع بيانات الموقع بشكل دوري ومُخفي للمصدر واستخدام هذه البيانات لتحسين دقة الموقع والخدمات القائمة على الموقع.</string>
<string name="pref_location_source_title">اختر خدمة تحديد الموقع عبر الإنترنت</string>
<string name="pref_location_custom_url_input_hint">رابط مخصص للخدمة</string>
<string name="pref_location_custom_source_title">مخصص</string>
<string name="pref_location_custom_url_summary">يسمح هذا بتعيين رابط مخصص للخدمة. القيم غير الصالحة قد تؤدي إلى عدم استجابة خدمات الموقع.</string>
<string name="pref_location_source_terms">الشروط / الخصوصية</string>
<string name="pref_location_source_suggested">مقترح</string>
<string name="pref_location_import_export_title">استيراد أو تصدير بيانات الموقع</string>
<string name="notification_config_required_title">التهيئة مطلوبة</string>
<string name="notification_config_required_text_online_sources">لمتابعة استخدام خدمات الموقع عبر الإنترنت، تحتاج إلى تحديد خدمة بيانات الموقع.</string>
<string name="location_data_export_wifi_title">تصدير قاعدة بيانات مواقع الـ Wi-Fi المحلية</string>
<string name="location_data_export_cell_title">تصدير قاعدة بيانات مواقع الأبراج الخلوية المحلية</string>
<string name="location_data_import_result_toast">تم استيراد %1$d سِجِل.</string>
<string name="pref_location_wifi_moving_enabled_summary">حصول على موقع شبكة الـ Wi-Fi مباشرةً من نقاط الاتصال المدعومة عند الاتصال بها.</string>
<string name="location_settings_dialog_message_details_end_paragraph">لمزيد من التفاصيل، انتقل إلى إعدادات الموقع.</string>
<string name="pref_location_custom_url_details">يتم إلحاق المسار \"v1/geolocate/\" تلقائيًا. إذا كان موفِّر الموقع يتطلب مفتاحًا، فيمكن إلحاقه كمعلمة استعلام إلى جذر الرابط.</string>
<string name="location_data_import_title">استيراد بيانات الموقع من ملف</string>
<string name="prefcat_app_last_location">آخر موقع تم الإبلاغ عنه</string>
</resources>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="pref_location_wifi_learning_enabled_summary">Atroxa llocalmente la llocalización de les redes Wi-Fi al usar el GPS.</string>
<string name="location_app_last_access_at">Últimu accesu: <xliff:g example="Yesterday, 02:20 PM">%1$s</xliff:g></string>
<string name="pref_location_cell_learning_enabled_summary">Atroxa la llocalización de la rede móvil cuando s\'usa\'l GPS.</string>
<string name="fragment_location_apps_title">Aplicaciones con accesu a la llocalización</string>
<string name="pref_location_app_force_coarse_summary">Siempres devuelve llocalizaciones aproximaes a esta aplicación, inorando\'l so nivel de permisos.</string>
<string name="pref_location_wifi_moving_enabled_summary">Recupera direutamente la llocalización de los puntos Wi-Fi compatibles al conectase.</string>
<string name="prefcat_location_cell_title">Llocalización pela rede móvil</string>
<string name="pref_location_wifi_moving_enabled_title">Solicitar al puntu Wi-Fi</string>
<string name="pref_location_app_force_coarse_title">Forciar la llocalización aproximada</string>
<string name="pref_geocoder_nominatim_enabled_title">Usar Nominatim</string>
<string name="service_name_location">Llocalización</string>
<string name="prefcat_location_apps_title">Accesu recién</string>
<string name="pref_geocoder_nominatim_enabled_summary">Resuelve direiciones con OpenStreetMap Nominatim.</string>
<string name="prefcat_location_wifi_title">Llocalización per Wi-Fi</string>
<string name="prefcat_geocoder_title">Resolvedor de direiciones</string>
</resources>

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="service_name_location">Məkan</string>
<string name="prefcat_location_apps_title">Son giriş</string>
<string name="prefcat_location_wifi_title">Wi-Fi məkanı</string>
<string name="prefcat_location_cell_title">Mobil şəbəkə məkanı</string>
<string name="prefcat_geocoder_title">Ünvan qəbuledici</string>
<string name="pref_location_wifi_moving_enabled_title">Hotspot-dan sorğu</string>
<string name="pref_location_wifi_moving_enabled_summary">Qoşulduqda Wi-Fi məkanını birbaşa dəstəklənən Hotspots-dan əldə edin.</string>
<string name="pref_location_wifi_learning_enabled_title">GPS-dən xatırla</string>
<string name="pref_location_wifi_learning_enabled_summary">GPS istifadə edildikdə Wi-Fi məkanların yerli olaraq saxla.</string>
<string name="pref_location_cell_learning_enabled_title">GPS-dən xatırla</string>
<string name="pref_location_cell_learning_enabled_summary">GPS istifadə edildikdə mobil şəbəkə yerlərin yerli olaraq saxla.</string>
<string name="pref_geocoder_nominatim_enabled_title">Nominatim istifadə et</string>
<string name="fragment_location_apps_title">Məkan girişi olan tətbiqlər</string>
<string name="location_app_last_access_at">Son giriş: <xliff:g example="Dünən, 02:20 PM"> %1$s</xliff:g></string>
<string name="pref_location_app_force_coarse_title">Kobud yeri zorla</string>
<string name="pref_location_app_force_coarse_summary">İcazə səviyyəsinə məhəl qoymadan həmişə bu tətbiqə kobud yerləri qaytarın.</string>
<string name="location_settings_dialog_message_title_to_continue">Davam etmək üçün microG-nin məkan xidmətin istifadə edən cihaz məkanın aktiv et</string>
<string name="location_settings_dialog_message_details_start_paragraph">Cihazınız aşağıdakıları tələb edəcək:</string>
<string name="location_settings_dialog_message_location_services_gps_and_nlp">GPS, WiFi, mobil şəbəkələr və sensorları istifadə edin</string>
<string name="location_settings_dialog_message_grant_permissions">microG Xidmətinə məkan icazələri ver</string>
<string name="location_settings_dialog_message_details_end_paragraph">Təfərrüatlar üçün məkan tənzimləmələrinə keçin.</string>
<string name="location_settings_dialog_btn_cancel">Xeyr, təşəkkür</string>
<string name="location_settings_dialog_btn_sure">Oldu</string>
<string name="pref_location_custom_url_details">/v1/geolocate yolu avtomatik olaraq əlavə olunur. Məkan təminatçısı açar tələb edirsə, o, kök URL-ə sorğu faktoru kimi əlavə edilə bilər.</string>
<string name="pref_location_custom_url_input_hint">Fərdi xidmət URL-i</string>
<string name="pref_geocoder_nominatim_enabled_summary">OpenStreetMap Nominatim istifadə edərək ünvanları qəbul et.</string>
<string name="location_settings_dialog_message_title_for_better_experience">Daha yaxşı təcrübə üçün microG-nin məkan xidmətin istifadə edən cihazın məkanın aktiv et</string>
<string name="pref_location_custom_url_summary">Bu, fərdi xidmət URL-i təyin etməyə imkan verir. Yanlış dəyərlər məkan xidmətlərinin cavab verməməsi və ya tamamilə əlçatan olmaması ilə nəticələnə bilər.</string>
<string name="location_settings_dialog_message_gls_consent">microG məkan xidmətin istifadə edin; bu xidmətin bir hissəsi kimi microG məkan məlumatını vaxtaşırı toplaya və məkan dəqiqliyini və məkan əsaslı xidmətləri təkmilləşdirmək üçün bu məlumatı gizli şəkildə istifadə edə bilər.</string>
</resources>

Some files were not shown because too many files have changed in this diff Show more