Repo Created
This commit is contained in:
parent
eb305e2886
commit
a8c22c65db
4784 changed files with 329907 additions and 2 deletions
51
play-services-location/core/base/build.gradle
Normal file
51
play-services-location/core/base/build.gradle
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
~ SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
<manifest />
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
84
play-services-location/core/build.gradle
Normal file
84
play-services-location/core/build.gradle
Normal 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
|
||||
}
|
||||
}
|
||||
58
play-services-location/core/provider/build.gradle
Normal file
58
play-services-location/core/provider/build.gradle
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 (lac’s) 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
|
||||
)
|
||||
|
|
@ -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 (lac’s) 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,
|
||||
)
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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 device’s 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,
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(":", "")
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
34
play-services-location/core/src/huawei/AndroidManifest.xml
Normal file
34
play-services-location/core/src/huawei/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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 |
BIN
play-services-location/core/src/huawei/res/drawable/arrow.png
Normal file
BIN
play-services-location/core/src/huawei/res/drawable/arrow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 B |
BIN
play-services-location/core/src/huawei/res/drawable/ic_app.png
Normal file
BIN
play-services-location/core/src/huawei/res/drawable/ic_app.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
|
|
@ -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 |
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
88
play-services-location/core/src/main/AndroidManifest.xml
Normal file
88
play-services-location/core/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
||||
16
play-services-location/core/src/main/res/drawable/ic_gps.xml
Normal file
16
play-services-location/core/src/main/res/drawable/ic_gps.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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, Wi‑Fi, 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
Loading…
Add table
Add a link
Reference in a new issue